bigbiggerbiggestbot/webapp/app.js
Danny 9f146d60fa feat: last-session recall when starting an exercise
When you start an exercise, the Mini App now fetches the most
recent time you logged it and shows a hint line in the sets card
("Last time: 8×60, 6×60, 5×60 · 3 days ago"), plus pre-fills the
weight input with the last set's weight.

- db.get_last_exercise(user_id, name): most recent non-deleted
  entry, case-insensitive name match, sets_detail parsed.
- GET /api/exercises/last?name=<name>.
- webapp: loadLastSession() on startExercise + draft restore;
  hint cleared on editExercise (the set rows are the reference
  there). Pre-fill only when the weight field is empty and no
  sets logged yet, so it never clobbers user input.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 12:52:37 +02:00

1066 lines
34 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ── Telegram Web App init ───────────────────────────────────────
const tg = window.Telegram.WebApp;
tg.ready();
tg.expand();
const API = window.location.origin + "/api";
const userId = tg.initDataUnsafe?.user?.id;
if (!userId) {
document.getElementById("app").innerHTML =
'<div class="empty-state"><p>Please open this app from Telegram.</p></div>';
}
// ── API helpers ─────────────────────────────────────────────────
async function api(method, path, body = null) {
const opts = {
method,
headers: {
"Content-Type": "application/json",
"X-Telegram-Init-Data": tg.initData,
},
};
if (body) opts.body = JSON.stringify(body);
const res = await fetch(API + path, opts);
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error || `API error ${res.status}`);
}
return res.json();
}
// ── Event logging (fire-and-forget) ─────────────────────────────
function logEvent(kind, data) {
if (!userId) return;
try {
fetch(API + "/events", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Telegram-Init-Data": tg.initData,
},
body: JSON.stringify({ kind, data: data || null }),
keepalive: true,
}).catch(() => {});
} catch (e) {
// Never let logging break anything
}
}
// ── Toast ───────────────────────────────────────────────────────
function showToast(msg) {
let toast = document.querySelector(".toast");
if (!toast) {
toast = document.createElement("div");
toast.className = "toast";
document.body.appendChild(toast);
}
toast.textContent = msg;
toast.classList.add("show");
setTimeout(() => toast.classList.remove("show"), 2000);
}
// ── Draft persistence (localStorage) ────────────────────────────
const DRAFT_EXPIRY_MS = 24 * 60 * 60 * 1000; // 24 hours
function draftKey() {
return "draft_workout_" + userId;
}
function saveDraft() {
if (!userId) return;
try {
const draft = {
workout,
currentExercise,
currentSets: getCurrentSets(),
note: noteInput?.value || "",
editingWorkoutId,
activeView: document.querySelector(".tab.active")?.dataset.view || "log",
rawDetailsOpen: document.getElementById("raw-details")?.open || false,
lastSetAt,
savedAt: Date.now(),
};
localStorage.setItem(draftKey(), JSON.stringify(draft));
} catch (e) {
console.warn("Failed to save draft", e);
}
}
function clearDraft() {
if (!userId) return;
try {
localStorage.removeItem(draftKey());
} catch (e) {
console.warn("Failed to clear draft", e);
}
}
function restoreDraft() {
if (!userId) return false;
try {
const raw = localStorage.getItem(draftKey());
if (!raw) return false;
const draft = JSON.parse(raw);
// Expire old drafts
if (draft.savedAt && Date.now() - draft.savedAt > DRAFT_EXPIRY_MS) {
clearDraft();
return false;
}
// Restore completed exercises
if (Array.isArray(draft.workout) && draft.workout.length > 0) {
workout = draft.workout;
renderWorkout();
}
// Restore in-progress exercise
if (draft.currentExercise) {
currentExercise = draft.currentExercise;
setsSection.classList.remove("hidden");
setsLabel.textContent = currentExercise.name;
setsList.innerHTML = "";
if (Array.isArray(draft.currentSets)) {
draft.currentSets.forEach((s) => addSetToDOM(s.reps, s.weight_kg));
}
loadLastSession(currentExercise.name);
}
// Restore active tab
if (draft.activeView && draft.activeView !== "log") {
document.querySelectorAll(".tab").forEach((t) => t.classList.remove("active"));
document.querySelectorAll(".view").forEach((v) => v.classList.remove("active"));
const tab = document.querySelector(`.tab[data-view="${draft.activeView}"]`);
if (tab) {
tab.classList.add("active");
document.getElementById("view-" + draft.activeView)?.classList.add("active");
if (draft.activeView === "history") loadHistory();
if (draft.activeView === "stats") loadStats();
}
}
// Restore note
if (draft.note && noteInput) {
noteInput.value = draft.note;
}
// Restore editing state
if (draft.editingWorkoutId) {
editingWorkoutId = draft.editingWorkoutId;
updateEditingUI();
}
// Restore raw details open state
if (draft.rawDetailsOpen) {
const details = document.getElementById("raw-details");
if (details) details.open = true;
}
// Resume rest timer if it was running
if (currentExercise && getCurrentSets().length > 0 && draft.lastSetAt) {
resumeRestTimer(draft.lastSetAt);
}
syncEditorUI();
return true;
} catch (e) {
console.warn("Failed to restore draft", e);
clearDraft();
return false;
}
}
// ── Tab navigation ──────────────────────────────────────────────
document.querySelectorAll(".tab").forEach((tab) => {
tab.addEventListener("click", () => {
document.querySelectorAll(".tab").forEach((t) => t.classList.remove("active"));
document.querySelectorAll(".view").forEach((v) => v.classList.remove("active"));
tab.classList.add("active");
document.getElementById("view-" + tab.dataset.view).classList.add("active");
tg.HapticFeedback.selectionChanged();
saveDraft();
if (tab.dataset.view === "history") loadHistory();
if (tab.dataset.view === "stats") loadStats();
});
});
// Persist raw details toggle
document.getElementById("raw-details")?.addEventListener("toggle", saveDraft);
// Persist note changes (debounced)
let noteSaveTimer;
document.getElementById("inp-note")?.addEventListener("input", () => {
clearTimeout(noteSaveTimer);
noteSaveTimer = setTimeout(saveDraft, 400);
});
// ── State ───────────────────────────────────────────────────────
let workout = [];
let knownExercises = [];
let currentExercise = null;
let editingWorkoutId = null; // non-null when editing a saved workout
let lastSetAt = null; // ms-epoch of most recent addSet, or null
let restTimerInterval = null;
let settings = { rest_timer: true, weight_sign_button: true };
function settingEnabled(key, def = true) {
const v = settings[key];
return v === undefined ? def : !!v;
}
// ── Rest timer ──────────────────────────────────────────────────
function _fmtRest(ms) {
const total = Math.max(0, Math.floor(ms / 1000));
const m = Math.floor(total / 60);
const s = total % 60;
return String(m).padStart(2, "0") + ":" + String(s).padStart(2, "0");
}
function updateRestTimer() {
const el = document.getElementById("rest-timer");
if (!el) return;
const setCount = setsList ? setsList.querySelectorAll(".set-entry").length : 0;
if (!settingEnabled("rest_timer") || lastSetAt === null || !currentExercise || setCount === 0) {
el.classList.add("hidden");
return;
}
el.classList.remove("hidden");
el.textContent = _fmtRest(Date.now() - lastSetAt);
}
function startRestTimer() {
lastSetAt = Date.now();
if (!restTimerInterval) {
restTimerInterval = setInterval(updateRestTimer, 1000);
}
updateRestTimer();
}
function stopRestTimer() {
lastSetAt = null;
if (restTimerInterval) {
clearInterval(restTimerInterval);
restTimerInterval = null;
}
updateRestTimer();
}
function resumeRestTimer(ts) {
// Called during draft restore when lastSetAt was persisted.
if (typeof ts !== "number" || !Number.isFinite(ts)) return;
lastSetAt = ts;
if (!restTimerInterval) {
restTimerInterval = setInterval(updateRestTimer, 1000);
}
updateRestTimer();
}
// ── Structured Log View ─────────────────────────────────────────
const nameInput = document.getElementById("inp-exercise-name");
const nameInputRow = document.querySelector(".exercise-name-row");
const btnAddExercise = document.getElementById("btn-add-exercise");
const autocompleteList = document.getElementById("autocomplete-list");
const setsSection = document.getElementById("sets-section");
const setsLabel = document.getElementById("sets-label");
const setsList = document.getElementById("sets-list");
const repsInput = document.getElementById("inp-reps");
const weightInput = document.getElementById("inp-weight");
const btnWeightSign = document.getElementById("btn-weight-sign");
const btnAddSet = document.getElementById("btn-add-set");
const btnSaveWorkout = document.getElementById("btn-save-workout");
const workoutExercises = document.getElementById("workout-exercises");
const notesSection = document.getElementById("notes-section");
const noteInput = document.getElementById("inp-note");
// Exercise name input — autocomplete
nameInput.addEventListener("input", () => {
const val = nameInput.value.trim().toLowerCase();
autocompleteList.innerHTML = "";
if (!val) {
autocompleteList.classList.remove("visible");
return;
}
const matches = knownExercises.filter((n) =>
n.toLowerCase().includes(val)
).slice(0, 8);
if (matches.length === 0) {
autocompleteList.classList.remove("visible");
return;
}
matches.forEach((name) => {
const item = document.createElement("div");
item.className = "autocomplete-item";
item.textContent = name;
item.addEventListener("click", () => {
nameInput.value = name;
autocompleteList.innerHTML = "";
autocompleteList.classList.remove("visible");
startExercise(name);
});
autocompleteList.appendChild(item);
});
autocompleteList.classList.add("visible");
});
// Close autocomplete on outside click
document.addEventListener("click", (e) => {
if (!e.target.closest("#add-exercise-card")) {
autocompleteList.innerHTML = "";
autocompleteList.classList.remove("visible");
}
});
// Enter on name input → start exercise
nameInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
const name = nameInput.value.trim();
if (name) {
autocompleteList.innerHTML = "";
autocompleteList.classList.remove("visible");
startExercise(name);
}
}
});
btnAddExercise.addEventListener("click", () => {
const name = nameInput.value.trim();
if (name) {
autocompleteList.innerHTML = "";
autocompleteList.classList.remove("visible");
startExercise(name);
}
});
const btnDeleteExercise = document.getElementById("btn-delete-exercise");
function startExercise(name) {
if (currentExercise && getCurrentSets().length > 0) {
finishCurrentExercise();
}
currentExercise = { name, machine_id: null };
setsSection.classList.remove("hidden");
setsLabel.textContent = name;
setsList.innerHTML = "";
nameInput.value = "";
repsInput.value = "";
weightInput.value = "";
syncWeightSignUI();
repsInput.focus();
notesSection.classList.remove("hidden");
stopRestTimer();
syncEditorUI();
loadLastSession(name);
tg.HapticFeedback.selectionChanged();
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() {
return Array.from(setsList.querySelectorAll(".set-entry")).map((el) => ({
reps: parseInt(el.dataset.reps),
weight_kg: parseFloat(el.dataset.weight),
}));
}
// Add a set entry to the DOM (used by both addSet and restoreDraft)
function addSetToDOM(reps, weight) {
const entry = document.createElement("div");
entry.className = "set-entry";
entry.dataset.reps = reps;
entry.dataset.weight = weight;
const label = weight
? `${reps} x ${fmtWeight(weight)}kg`
: `${reps} reps`;
entry.innerHTML = `
<span class="set-text">${label}</span>
<button class="btn-remove" title="Remove">&times;</button>
`;
entry.querySelector(".btn-remove").addEventListener("click", () => {
entry.remove();
syncEditorUI();
updateRestTimer();
tg.HapticFeedback.selectionChanged();
saveDraft();
});
setsList.appendChild(entry);
}
// Parse a weight string, accepting both comma and dot as decimal separators
function parseWeight(s) {
if (!s) return 0;
return parseFloat(String(s).replace(",", ".")) || 0;
}
// Add set from input fields
function addSet() {
const reps = parseInt(repsInput.value);
if (!reps || reps <= 0) {
showToast("Enter reps");
tg.HapticFeedback.notificationOccurred("error");
return;
}
const weight = parseWeight(weightInput.value);
addSetToDOM(reps, weight);
syncEditorUI();
startRestTimer();
logEvent("set.add", {
exercise: currentExercise?.name || null,
reps,
weight_kg: weight,
});
repsInput.value = "";
weightInput.value = weight ? String(weight) : "";
syncWeightSignUI();
repsInput.focus();
tg.HapticFeedback.impactOccurred("light");
saveDraft();
}
btnAddSet.addEventListener("click", addSet);
function syncWeightSignUI() {
const v = weightInput.value.trim();
const negative = v.startsWith("-");
btnWeightSign.classList.toggle("active", negative);
}
btnWeightSign.addEventListener("click", () => {
// Flip the sign of the current weight value. If empty, start with "-"
// so the user can type digits after it (iOS numeric keypad has no minus).
const v = weightInput.value.trim();
if (v === "" || v === "-") {
weightInput.value = v === "-" ? "" : "-";
} else if (v.startsWith("-")) {
weightInput.value = v.slice(1);
} else {
weightInput.value = "-" + v;
}
syncWeightSignUI();
weightInput.focus();
tg.HapticFeedback.selectionChanged();
});
weightInput.addEventListener("input", syncWeightSignUI);
repsInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
if (repsInput.value.trim()) {
weightInput.focus();
}
// Empty reps → stay put, keyboard stays open
}
});
weightInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
if (!repsInput.value.trim()) {
// No reps yet — jump back to reps
repsInput.focus();
return;
}
addSet();
// After addSet, focus is already on repsInput (set inside addSet)
}
});
// Delete the current exercise being edited (discard without saving back)
btnDeleteExercise.addEventListener("click", () => {
currentExercise = null;
setsSection.classList.add("hidden");
setsList.innerHTML = "";
stopRestTimer();
renderWorkout();
tg.HapticFeedback.notificationOccurred("warning");
saveDraft();
});
// Finish current exercise → add to workout
function finishCurrentExercise() {
if (!currentExercise) return;
const sets = getCurrentSets();
if (sets.length === 0) return;
workout.push({
name: currentExercise.name,
machine_id: null,
sets_detail: sets,
sets: sets.length,
reps: sets[0].reps,
weight_kg: sets[0].weight_kg,
});
currentExercise = null;
setsSection.classList.add("hidden");
setsList.innerHTML = "";
stopRestTimer();
renderWorkout();
saveDraft();
}
function syncEditorUI() {
const currentSetCount = getCurrentSets().length;
const canSave = workout.length > 0 || currentSetCount > 0;
btnSaveWorkout.classList.toggle("hidden", !canSave);
// Hide the "add exercise" input while the current exercise has no sets
// yet — prevents accidentally discarding it by typing a new name.
const hideNameInput = currentExercise !== null && currentSetCount === 0;
nameInputRow.classList.toggle("hidden", hideNameInput);
if (hideNameInput) {
autocompleteList.innerHTML = "";
autocompleteList.classList.remove("visible");
}
// Escape hatch whenever a current exercise exists — needed now that the
// name input is sometimes hidden.
btnDeleteExercise.classList.toggle("hidden", currentExercise === null);
}
function renderWorkout() {
workoutExercises.innerHTML = "";
const hasAny = workout.length > 0 || currentExercise !== null;
syncEditorUI();
// Show notes section when there's any workout activity
if (hasAny) {
notesSection.classList.remove("hidden");
} else {
notesSection.classList.add("hidden");
}
workout.forEach((ex, idx) => {
const card = document.createElement("div");
card.className = "exercise-card";
const machine = ex.machine_id ? ` (${ex.machine_id})` : "";
const setsHtml = ex.sets_detail
.map((s) => (s.weight_kg ? `${s.reps}x${fmtWeight(s.weight_kg)}kg` : `${s.reps}`))
.join(", ");
card.innerHTML = `
<div class="exercise-card-header">
<span class="exercise-card-name">${ex.name}${machine}</span>
<button class="btn-remove btn-edit" title="Edit">&#9998;</button>
</div>
<div class="exercise-card-sets">${setsHtml}</div>
`;
card.querySelector(".btn-edit").addEventListener("click", (e) => {
e.stopPropagation();
editExercise(idx);
});
workoutExercises.appendChild(card);
});
}
// Reopen a saved exercise to add/remove sets
function editExercise(idx) {
// Finish any in-progress exercise first
if (currentExercise && getCurrentSets().length > 0) {
finishCurrentExercise();
}
const ex = workout[idx];
if (!ex) return;
// Pop it back into the current-exercise slot
workout.splice(idx, 1);
currentExercise = { name: ex.name, machine_id: ex.machine_id };
setsSection.classList.remove("hidden");
setsLabel.textContent = ex.name;
setsList.innerHTML = "";
(ex.sets_detail || []).forEach((s) => addSetToDOM(s.reps, s.weight_kg));
// Pre-fill weight input with last set's weight for convenience
const lastWeight = ex.sets_detail?.length ? ex.sets_detail[ex.sets_detail.length - 1].weight_kg : 0;
weightInput.value = lastWeight ? String(lastWeight) : "";
syncWeightSignUI();
repsInput.value = "";
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();
renderWorkout();
tg.HapticFeedback.selectionChanged();
saveDraft();
}
// ── Edit saved workout ──────────────────────────────────────────
function editSavedWorkout(workoutData) {
// Clear any in-progress work
if (currentExercise && getCurrentSets().length > 0) {
finishCurrentExercise();
}
workout = [];
currentExercise = null;
setsList.innerHTML = "";
setsSection.classList.add("hidden");
stopRestTimer();
// Load all exercises from the saved workout
(workoutData.superset_groups || []).forEach((group) => {
group.forEach((ex) => {
workout.push({
name: ex.name,
machine_id: ex.machine_id || null,
sets_detail: ex.sets_detail || [],
sets: ex.sets || 0,
reps: ex.reps || 0,
weight_kg: ex.weight_kg || 0,
});
});
});
// Set note
noteInput.value = workoutData.note || "";
// Mark as editing
editingWorkoutId = workoutData.id;
// Switch to Log tab
document.querySelectorAll(".tab").forEach((t) => t.classList.remove("active"));
document.querySelectorAll(".view").forEach((v) => v.classList.remove("active"));
document.querySelector('.tab[data-view="log"]').classList.add("active");
document.getElementById("view-log").classList.add("active");
renderWorkout();
updateEditingUI();
saveDraft();
tg.HapticFeedback.impactOccurred("medium");
}
function updateEditingUI() {
const banner = document.getElementById("editing-banner");
if (editingWorkoutId) {
if (banner) banner.classList.remove("hidden");
btnSaveWorkout.textContent = "Update Workout";
} else {
if (banner) banner.classList.add("hidden");
btnSaveWorkout.textContent = "Save Workout";
}
}
function cancelEdit() {
editingWorkoutId = null;
workout = [];
currentExercise = null;
noteInput.value = "";
setsList.innerHTML = "";
setsSection.classList.add("hidden");
stopRestTimer();
renderWorkout();
updateEditingUI();
clearDraft();
// Switch to history tab
document.querySelectorAll(".tab").forEach((t) => t.classList.remove("active"));
document.querySelectorAll(".view").forEach((v) => v.classList.remove("active"));
document.querySelector('.tab[data-view="history"]').classList.add("active");
document.getElementById("view-history").classList.add("active");
loadHistory();
tg.HapticFeedback.selectionChanged();
}
document.getElementById("btn-cancel-edit")?.addEventListener("click", cancelEdit);
// Save workout
btnSaveWorkout.addEventListener("click", async () => {
if (currentExercise && getCurrentSets().length > 0) {
finishCurrentExercise();
}
if (workout.length === 0) {
showToast("Add at least one exercise");
tg.HapticFeedback.notificationOccurred("error");
return;
}
tg.HapticFeedback.impactOccurred("medium");
const superset_groups = workout.map((ex) => [ex]);
const note = noteInput.value.trim() || null;
try {
let data;
if (editingWorkoutId) {
data = await api("PUT", `/workouts/${editingWorkoutId}`, { superset_groups, note });
showToast("Workout updated!");
} else {
data = await api("POST", "/workouts", { superset_groups, note });
showToast("Workout #" + (data.user_number ?? data.workout_id) + " saved!");
}
workout = [];
currentExercise = null;
editingWorkoutId = null;
noteInput.value = "";
stopRestTimer();
renderWorkout();
updateEditingUI();
clearDraft();
tg.HapticFeedback.notificationOccurred("success");
} catch (e) {
showToast(e.message);
tg.HapticFeedback.notificationOccurred("error");
}
});
// ── Raw text fallback ───────────────────────────────────────────
document.getElementById("btn-save-raw").addEventListener("click", async () => {
const raw = document.getElementById("inp-raw").value.trim();
if (!raw) {
showToast("Enter your workout first");
tg.HapticFeedback.notificationOccurred("error");
return;
}
tg.HapticFeedback.impactOccurred("medium");
try {
const data = await api("POST", "/workouts", { raw_text: raw });
document.getElementById("inp-raw").value = "";
clearDraft();
showToast("Workout #" + (data.user_number ?? data.workout_id) + " saved!");
tg.HapticFeedback.notificationOccurred("success");
} catch (e) {
showToast(e.message);
tg.HapticFeedback.notificationOccurred("error");
}
});
// ── History View ────────────────────────────────────────────────
let historyOffset = 0;
async function loadHistory(append = false) {
try {
if (!append) historyOffset = 0;
const data = await api("GET", `/workouts?limit=10&offset=${historyOffset}`);
const container = document.getElementById("history-list");
const noHistory = document.getElementById("no-history");
const loadMore = document.getElementById("btn-load-more");
if (!append) container.innerHTML = "";
if (!data.workouts || data.workouts.length === 0) {
if (!append) noHistory.style.display = "block";
loadMore.style.display = "none";
return;
}
noHistory.style.display = "none";
data.workouts.forEach((w) => {
const card = document.createElement("div");
card.className = "history-card";
const ts = new Date(w.timestamp);
const dateStr = ts.toLocaleDateString(undefined, {
weekday: "short",
day: "numeric",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
let volume = 0;
(w.superset_groups || []).forEach((group) => {
group.forEach((ex) => {
const details = ex.sets_detail || [];
if (details.length > 0) {
details.forEach((s) => { volume += s.reps * s.weight_kg; });
} else {
volume += ex.sets * ex.reps * ex.weight_kg;
}
});
});
let groupsHtml = "";
(w.superset_groups || []).forEach((group) => {
const isSuperset = group.length > 1;
if (isSuperset) {
groupsHtml += '<div class="history-group"><div class="superset-label">Superset</div>';
} else {
groupsHtml += '<div class="history-group">';
}
group.forEach((ex) => {
const machine = ex.machine_id ? ` <span class="ex-machine">(${ex.machine_id})</span>` : "";
const details = ex.sets_detail || [];
let detailStr;
if (details.length > 0 && !details.every(
(d) => d.reps === details[0].reps && d.weight_kg === details[0].weight_kg
)) {
detailStr = details.map((d) =>
d.weight_kg ? `${d.reps}x${fmtWeight(d.weight_kg)}kg` : `${d.reps}`
).join(", ");
} else {
const w = ex.weight_kg;
detailStr = w ? `${ex.sets}x${ex.reps}x${fmtWeight(w)}kg` : `${ex.sets}x${ex.reps}`;
}
groupsHtml += `
<div class="history-exercise">
<span class="ex-name">${ex.name}</span>${machine}
<span class="ex-detail"> &mdash; ${detailStr}</span>
</div>`;
});
groupsHtml += "</div>";
});
card.innerHTML = `
<div class="history-header">
<span class="history-date">#${w.user_number} &middot; ${dateStr}</span>
<div class="history-header-right">
<span class="history-volume">${Math.round(volume)} kg vol</span>
<button class="btn-remove btn-edit btn-history-edit" title="Edit">&#9998;</button>
<button class="btn-remove btn-history-delete" title="Delete">&#128465;</button>
</div>
</div>
${groupsHtml}
`;
card.querySelector(".btn-history-edit").addEventListener("click", (e) => {
e.stopPropagation();
editSavedWorkout(w);
});
card.querySelector(".btn-history-delete").addEventListener("click", (e) => {
e.stopPropagation();
confirmDeleteWorkout(w);
});
container.appendChild(card);
});
historyOffset += data.workouts.length;
loadMore.style.display = historyOffset < data.total ? "block" : "none";
} catch (e) {
console.error("Failed to load history", e);
}
}
document.getElementById("btn-load-more").addEventListener("click", () => {
loadHistory(true);
});
function confirmDeleteWorkout(w) {
const label = "Workout #" + (w.user_number ?? w.id);
const prompt = "Delete " + label + "? This can't be undone.";
const onConfirm = async (ok) => {
if (!ok) return;
try {
await api("DELETE", "/workouts/" + w.id);
showToast(label + " deleted");
tg.HapticFeedback.notificationOccurred("success");
loadHistory();
} catch (e) {
showToast(e.message || "Delete failed");
tg.HapticFeedback.notificationOccurred("error");
}
};
if (tg && typeof tg.showConfirm === "function") {
tg.showConfirm(prompt, onConfirm);
} else {
onConfirm(window.confirm(prompt));
}
}
// ── Stats View ──────────────────────────────────────────────────
async function loadStats() {
try {
const data = await api("GET", "/stats");
const container = document.getElementById("stats-content");
if (data.total_workouts === 0) {
container.innerHTML = '<div class="empty-state"><p>No workouts yet</p></div>';
return;
}
container.innerHTML = `
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value">${data.total_workouts}</div>
<div class="stat-label">Workouts</div>
</div>
<div class="stat-card">
<div class="stat-value">${data.unique_exercises}</div>
<div class="stat-label">Exercises</div>
</div>
<div class="stat-card">
<div class="stat-value">${data.total_sets.toLocaleString()}</div>
<div class="stat-label">Total Sets</div>
</div>
<div class="stat-card">
<div class="stat-value">${Math.round(data.total_volume).toLocaleString()}</div>
<div class="stat-label">Volume (kg)</div>
</div>
</div>
`;
} catch (e) {
console.error("Failed to load stats", e);
}
}
// ── Helpers ─────────────────────────────────────────────────────
function fmtWeight(w) {
return w === Math.floor(w) ? Math.floor(w).toString() : w.toString();
}
// ── Settings ────────────────────────────────────────────────────
async function loadSettings() {
if (!userId) return;
try {
const data = await api("GET", "/settings");
settings = { rest_timer: true, weight_sign_button: true, ...(data.settings || {}) };
applySettingsToUI();
updateRestTimer();
} catch (e) {
console.error("Failed to load settings", e);
}
}
function applyWeightSignVisibility() {
if (!btnWeightSign) return;
btnWeightSign.classList.toggle("hidden", !settingEnabled("weight_sign_button"));
}
function applySettingsToUI() {
const restToggle = document.getElementById("setting-rest-timer");
if (restToggle) restToggle.checked = settingEnabled("rest_timer");
const signToggle = document.getElementById("setting-weight-sign");
if (signToggle) signToggle.checked = settingEnabled("weight_sign_button");
applyWeightSignVisibility();
}
async function saveSetting(key, value) {
// Optimistic: update locally first, then sync.
settings[key] = value;
updateRestTimer();
applyWeightSignVisibility();
try {
await api("PUT", "/settings", { [key]: value });
} catch (e) {
showToast("Could not save setting");
console.error(e);
}
}
document.getElementById("setting-rest-timer")?.addEventListener("change", (e) => {
saveSetting("rest_timer", e.target.checked);
});
document.getElementById("setting-weight-sign")?.addEventListener("change", (e) => {
saveSetting("weight_sign_button", e.target.checked);
});
// ── Version badge ───────────────────────────────────────────────
async function loadVersion() {
try {
const res = await fetch(API + "/version");
if (!res.ok) return;
const data = await res.json();
const badge = document.getElementById("version-badge");
if (badge && data.version) badge.textContent = data.version;
} catch (e) {
// Silent — footer just stays empty if unreachable
}
}
// ── Init ────────────────────────────────────────────────────────
async function init() {
loadVersion();
if (!userId) return;
logEvent("miniapp.open");
loadSettings(); // fire-and-forget; UI updates when ready
try {
const data = await api("GET", "/exercises");
knownExercises = data.exercises || [];
} catch (e) {
console.error("Failed to load exercises", e);
}
restoreDraft();
}
init();