bigbiggerbiggestbot/telegram-fitness-bot/webapp/app.js
Danny ae09ab2eec feat(tg-fitness-bot): add telegram fitness bot with web app
Telegram workout tracker bot with Mini App web UI, SQLite database,
API server, and cloudflared tunnel support.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 22:46:10 +02:00

361 lines
11 KiB
JavaScript
Raw 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>';
}
// ── State ───────────────────────────────────────────────────────
let activeWorkout = null;
let exercises = [];
let timerInterval = null;
// ── 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(message) {
let toast = document.querySelector(".toast");
if (!toast) {
toast = document.createElement("div");
toast.className = "toast";
document.body.appendChild(toast);
}
toast.textContent = message;
toast.classList.add("show");
setTimeout(() => toast.classList.remove("show"), 2000);
}
// ── 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();
});
});
// ── Timer ───────────────────────────────────────────────────────
function startTimer(startedAt) {
if (timerInterval) clearInterval(timerInterval);
const start = new Date(startedAt + "Z").getTime();
const el = document.getElementById("workout-timer");
function tick() {
const diff = Math.floor((Date.now() - start) / 1000);
const m = String(Math.floor(diff / 60)).padStart(2, "0");
const s = String(diff % 60).padStart(2, "0");
el.textContent = `${m}:${s}`;
}
tick();
timerInterval = setInterval(tick, 1000);
}
function stopTimer() {
if (timerInterval) {
clearInterval(timerInterval);
timerInterval = null;
}
}
// ── Render helpers ──────────────────────────────────────────────
function renderExerciseSelect() {
const sel = document.getElementById("sel-exercise");
sel.innerHTML = '<option value="">Select exercise…</option>';
exercises.forEach((ex) => {
const opt = document.createElement("option");
opt.value = ex.id;
opt.textContent = ex.name;
sel.appendChild(opt);
});
}
function renderSets(sets) {
const container = document.getElementById("sets-list");
container.innerHTML = "";
if (!sets || sets.length === 0) return;
// Group by exercise
const groups = {};
sets.forEach((s) => {
if (!groups[s.exercise_name]) groups[s.exercise_name] = [];
groups[s.exercise_name].push(s);
});
for (const [name, groupSets] of Object.entries(groups)) {
const group = document.createElement("div");
group.className = "exercise-group";
const heading = document.createElement("div");
heading.className = "exercise-group-name";
heading.textContent = name;
group.appendChild(heading);
groupSets.forEach((s, i) => {
const item = document.createElement("div");
item.className = "set-item";
item.innerHTML = `
<span class="set-number">Set ${i + 1}</span>
<span class="set-detail">${s.reps} reps × ${s.weight} kg</span>
<button class="set-delete" data-set-id="${s.id}">×</button>
`;
group.appendChild(item);
});
container.appendChild(group);
}
// Attach delete handlers
container.querySelectorAll(".set-delete").forEach((btn) => {
btn.addEventListener("click", async () => {
tg.HapticFeedback.impactOccurred("light");
await api("DELETE", `/sets/${btn.dataset.setId}`);
await refreshWorkout();
});
});
}
function renderExercisesList() {
const container = document.getElementById("exercises-list");
container.innerHTML = "";
exercises.forEach((ex) => {
const item = document.createElement("div");
item.className = "exercise-item";
item.innerHTML = `
<span>${ex.name}</span>
<button class="exercise-delete" data-exercise-id="${ex.id}">×</button>
`;
container.appendChild(item);
});
container.querySelectorAll(".exercise-delete").forEach((btn) => {
btn.addEventListener("click", async () => {
tg.HapticFeedback.impactOccurred("light");
try {
await api("DELETE", `/exercises/${btn.dataset.exerciseId}`);
await loadExercises();
showToast("Exercise deleted");
} catch (e) {
showToast(e.message);
}
});
});
}
async function renderHistory() {
try {
const data = await api("GET", "/workouts");
const container = document.getElementById("history-list");
const noHistory = document.getElementById("no-history");
container.innerHTML = "";
if (!data.workouts || data.workouts.length === 0) {
noHistory.style.display = "block";
return;
}
noHistory.style.display = "none";
data.workouts.forEach((w) => {
const card = document.createElement("div");
card.className = "history-card";
const date = new Date(w.started_at + "Z");
const dateStr = date.toLocaleDateString(undefined, {
weekday: "short",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
let exercisesHtml = "";
if (w.summary && w.summary.exercises) {
for (const [name, sets] of Object.entries(w.summary.exercises)) {
const setsStr = sets
.map((s) => `${s.reps}×${s.weight}kg`)
.join(", ");
exercisesHtml += `
<div class="history-exercise">
<div class="history-exercise-name">${name}</div>
<div class="history-exercise-sets">${setsStr}</div>
</div>
`;
}
}
card.innerHTML = `
<div class="history-card-header">
<span class="history-date">${dateStr}</span>
<span class="history-volume">${w.summary?.total_volume || 0} kg total</span>
</div>
${exercisesHtml}
`;
container.appendChild(card);
});
} catch (e) {
console.error("Failed to load history", e);
}
}
// ── Workout flow ────────────────────────────────────────────────
function showWorkoutState(active) {
document.getElementById("no-workout").style.display = active ? "none" : "block";
document.getElementById("active-workout").style.display = active ? "block" : "none";
}
async function refreshWorkout() {
try {
const data = await api("GET", "/workouts/active");
activeWorkout = data.workout;
if (activeWorkout) {
showWorkoutState(true);
startTimer(activeWorkout.started_at);
const setsData = await api("GET", `/workouts/${activeWorkout.id}/sets`);
renderSets(setsData.sets);
} else {
showWorkoutState(false);
stopTimer();
}
} catch (e) {
showWorkoutState(false);
stopTimer();
}
}
// ── Event listeners ─────────────────────────────────────────────
// Start workout
document.getElementById("btn-start-workout").addEventListener("click", async () => {
tg.HapticFeedback.impactOccurred("medium");
try {
const data = await api("POST", "/workouts");
activeWorkout = data.workout;
showWorkoutState(true);
startTimer(activeWorkout.started_at);
showToast("Workout started!");
} catch (e) {
showToast(e.message);
}
});
// Finish workout
document.getElementById("btn-finish-workout").addEventListener("click", async () => {
tg.HapticFeedback.notificationOccurred("success");
try {
await api("POST", `/workouts/${activeWorkout.id}/finish`);
activeWorkout = null;
showWorkoutState(false);
stopTimer();
showToast("Workout finished!");
renderHistory();
} catch (e) {
showToast(e.message);
}
});
// Add set
document.getElementById("btn-add-set").addEventListener("click", async () => {
const exerciseId = document.getElementById("sel-exercise").value;
const reps = parseInt(document.getElementById("inp-reps").value);
const weight = parseFloat(document.getElementById("inp-weight").value);
if (!exerciseId) {
showToast("Pick an exercise first");
tg.HapticFeedback.notificationOccurred("error");
return;
}
if (!reps || reps < 1) {
showToast("Enter valid reps");
tg.HapticFeedback.notificationOccurred("error");
return;
}
tg.HapticFeedback.impactOccurred("light");
try {
await api("POST", `/workouts/${activeWorkout.id}/sets`, {
exercise_id: parseInt(exerciseId),
reps,
weight: weight || 0,
});
await refreshWorkout();
showToast(`${reps} × ${weight} kg logged`);
} catch (e) {
showToast(e.message);
}
});
// Add exercise
document.getElementById("btn-add-exercise").addEventListener("click", async () => {
const input = document.getElementById("inp-exercise-name");
const name = input.value.trim();
if (!name) {
showToast("Enter exercise name");
tg.HapticFeedback.notificationOccurred("error");
return;
}
tg.HapticFeedback.impactOccurred("light");
try {
await api("POST", "/exercises", { name });
input.value = "";
await loadExercises();
showToast(`${name} added`);
} catch (e) {
showToast(e.message);
}
});
// Enter key to add exercise
document.getElementById("inp-exercise-name").addEventListener("keydown", (e) => {
if (e.key === "Enter") document.getElementById("btn-add-exercise").click();
});
// ── Data loading ────────────────────────────────────────────────
async function loadExercises() {
try {
const data = await api("GET", "/exercises");
exercises = data.exercises || [];
renderExerciseSelect();
renderExercisesList();
} catch (e) {
console.error("Failed to load exercises", e);
}
}
// ── Init ────────────────────────────────────────────────────────
async function init() {
if (!userId) return;
await loadExercises();
await refreshWorkout();
await renderHistory();
}
init();