Typing in the exercise-name input while an exercise was in progress but had no sets would silently replace it. Hide the name row in that state so the user commits to adding a set (or explicitly removes the exercise) before starting another. Because the name row was the only way to abandon a 0-set exercise, always show the "Remove exercise" escape hatch whenever a current exercise exists. Consolidate the save/name-row/delete visibility logic in syncEditorUI. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
782 lines
24 KiB
JavaScript
782 lines
24 KiB
JavaScript
// ── 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();
|
|
}
|
|
|
|
// ── 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,
|
|
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));
|
|
}
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
|
|
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
|
|
|
|
// ── 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 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 = "";
|
|
repsInput.focus();
|
|
notesSection.classList.remove("hidden");
|
|
syncEditorUI();
|
|
tg.HapticFeedback.selectionChanged();
|
|
saveDraft();
|
|
}
|
|
|
|
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">×</button>
|
|
`;
|
|
entry.querySelector(".btn-remove").addEventListener("click", () => {
|
|
entry.remove();
|
|
syncEditorUI();
|
|
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();
|
|
|
|
repsInput.value = "";
|
|
weightInput.value = weight ? String(weight) : "";
|
|
repsInput.focus();
|
|
tg.HapticFeedback.impactOccurred("light");
|
|
saveDraft();
|
|
}
|
|
|
|
btnAddSet.addEventListener("click", addSet);
|
|
|
|
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 = "";
|
|
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 = "";
|
|
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">✎</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) : "";
|
|
repsInput.value = "";
|
|
repsInput.focus();
|
|
|
|
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");
|
|
|
|
// 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");
|
|
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.workout_id + " saved!");
|
|
}
|
|
workout = [];
|
|
currentExercise = null;
|
|
editingWorkoutId = null;
|
|
noteInput.value = "";
|
|
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.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"> — ${detailStr}</span>
|
|
</div>`;
|
|
});
|
|
groupsHtml += "</div>";
|
|
});
|
|
|
|
card.innerHTML = `
|
|
<div class="history-header">
|
|
<span class="history-date">${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">✎</button>
|
|
</div>
|
|
</div>
|
|
${groupsHtml}
|
|
`;
|
|
|
|
card.querySelector(".btn-history-edit").addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
editSavedWorkout(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);
|
|
});
|
|
|
|
// ── 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();
|
|
}
|
|
|
|
// ── Init ────────────────────────────────────────────────────────
|
|
async function init() {
|
|
if (!userId) return;
|
|
try {
|
|
const data = await api("GET", "/exercises");
|
|
knownExercises = data.exercises || [];
|
|
} catch (e) {
|
|
console.error("Failed to load exercises", e);
|
|
}
|
|
restoreDraft();
|
|
}
|
|
|
|
init();
|