feat(tg-fitness-bot): structured mini-app, edit workouts, draft persistence

Rebuilt Log view: exercise name input with autocomplete, per-set
entry (reps x weight), exercise cards with edit/remove. Comma and
dot both work as decimal separators. Notes field. Collapsible raw
text input as fallback.

Edit saved workouts from History (pencil icon). Loads exercises
into editor, Save becomes Update, Cancel returns to History.

localStorage draft persistence: auto-saves on every state change,
restores on reopen (24h expiry), clears on save.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Danny 2026-04-13 20:41:47 +02:00
parent 6fb6207041
commit a529416599
3 changed files with 852 additions and 76 deletions

View file

@ -42,6 +42,110 @@ function showToast(msg) {
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;
}
return true;
} catch (e) {
console.warn("Failed to restore draft", e);
clearDraft();
return false;
}
}
// ── Tab navigation ──────────────────────────────────────────────
document.querySelectorAll(".tab").forEach((tab) => {
tab.addEventListener("click", () => {
@ -50,17 +154,433 @@ document.querySelectorAll(".tab").forEach((tab) => {
tab.classList.add("active");
document.getElementById("view-" + tab.dataset.view).classList.add("active");
tg.HapticFeedback.selectionChanged();
saveDraft();
// Lazy-load data when switching tabs
if (tab.dataset.view === "history") loadHistory();
if (tab.dataset.view === "stats") loadStats();
});
});
// ── Log View ────────────────────────────────────────────────────
let historyOffset = 0;
// Persist raw details toggle
document.getElementById("raw-details")?.addEventListener("toggle", saveDraft);
document.getElementById("btn-save").addEventListener("click", async () => {
// 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 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");
btnDeleteExercise.classList.add("hidden");
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">&times;</button>
`;
entry.querySelector(".btn-remove").addEventListener("click", () => {
entry.remove();
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);
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 = "";
btnDeleteExercise.classList.add("hidden");
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 renderWorkout() {
workoutExercises.innerHTML = "";
const hasAny = workout.length > 0 || currentExercise !== null;
if (workout.length === 0) {
btnSaveWorkout.classList.add("hidden");
} else {
btnSaveWorkout.classList.remove("hidden");
}
// 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) : "";
repsInput.value = "";
repsInput.focus();
btnDeleteExercise.classList.remove("hidden");
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");
@ -72,6 +592,7 @@ document.getElementById("btn-save").addEventListener("click", async () => {
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) {
@ -80,40 +601,8 @@ document.getElementById("btn-save").addEventListener("click", async () => {
}
});
// Load exercise name suggestions
async function loadSuggestions() {
try {
const data = await api("GET", "/exercises");
const container = document.getElementById("suggestion-chips");
const wrapper = document.getElementById("suggestions");
if (!data.exercises || data.exercises.length === 0) {
wrapper.style.display = "none";
return;
}
wrapper.style.display = "block";
container.innerHTML = "";
data.exercises.slice(0, 20).forEach((name) => {
const chip = document.createElement("button");
chip.className = "chip";
chip.textContent = name;
chip.addEventListener("click", () => {
const textarea = document.getElementById("inp-raw");
const val = textarea.value;
const suffix = name + ": ";
textarea.value = val ? val + "\n" + suffix : suffix;
textarea.focus();
tg.HapticFeedback.selectionChanged();
});
container.appendChild(chip);
});
} catch (e) {
console.error("Failed to load suggestions", e);
}
}
// ── History View ────────────────────────────────────────────────
let historyOffset = 0;
async function loadHistory(append = false) {
try {
@ -146,13 +635,15 @@ async function loadHistory(append = false) {
minute: "2-digit",
});
// Calculate volume
let volume = 0;
let totalSets = 0;
(w.superset_groups || []).forEach((group) => {
group.forEach((ex) => {
volume += ex.sets * ex.reps * ex.weight_kg;
totalSets += ex.sets;
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;
}
});
});
@ -166,10 +657,22 @@ async function loadHistory(append = false) {
}
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"> ${ex.sets}x${ex.reps}x${ex.weight_kg}kg</span>
<span class="ex-detail"> &mdash; ${detailStr}</span>
</div>`;
});
groupsHtml += "</div>";
@ -178,10 +681,19 @@ async function loadHistory(append = false) {
card.innerHTML = `
<div class="history-header">
<span class="history-date">${dateStr}</span>
<span class="history-volume">${Math.round(volume)} kg vol</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>
</div>
</div>
${groupsHtml}
`;
card.querySelector(".btn-history-edit").addEventListener("click", (e) => {
e.stopPropagation();
editSavedWorkout(w);
});
container.appendChild(card);
});
@ -204,7 +716,7 @@ async function loadStats() {
const container = document.getElementById("stats-content");
if (data.total_workouts === 0) {
container.innerHTML = '<div class="empty-state"><div class="empty-icon">📊</div><p>No workouts yet</p></div>';
container.innerHTML = '<div class="empty-state"><p>No workouts yet</p></div>';
return;
}
@ -233,10 +745,21 @@ async function loadStats() {
}
}
// ── Helpers ─────────────────────────────────────────────────────
function fmtWeight(w) {
return w === Math.floor(w) ? Math.floor(w).toString() : w.toString();
}
// ── Init ────────────────────────────────────────────────────────
async function init() {
if (!userId) return;
await loadSuggestions();
try {
const data = await api("GET", "/exercises");
knownExercises = data.exercises || [];
} catch (e) {
console.error("Failed to load exercises", e);
}
restoreDraft();
}
init();

View file

@ -17,25 +17,65 @@
<!-- ═══ LOG VIEW ═══ -->
<div id="view-log" class="view active">
<div class="card" id="text-input-card">
<textarea id="inp-raw" class="input" rows="6"
placeholder="Bench press: 4x8x35&#10;Lateral raise: 4x8x4&#10;&#10;Squats: 5x5x30"></textarea>
<div class="hint">Same format as the bot. Blank line = new group. Consecutive lines = superset.</div>
<button id="btn-save" class="btn btn-primary">Save Workout</button>
<!-- Editing banner -->
<div id="editing-banner" class="editing-banner hidden">
<span>Editing workout</span>
<button id="btn-cancel-edit" class="btn-link">Cancel</button>
</div>
<!-- Quick-add from history -->
<div id="suggestions" style="display:none">
<div class="section-label">Recent exercises</div>
<div id="suggestion-chips"></div>
<!-- Current workout exercises list -->
<div id="workout-exercises"></div>
<!-- Add exercise form -->
<div class="card" id="add-exercise-card">
<div class="exercise-name-row">
<input type="text" id="inp-exercise-name" class="input" placeholder="Exercise name" autocomplete="off" />
<button id="btn-add-exercise" class="btn-icon" title="Add exercise">+</button>
</div>
<div id="autocomplete-list" class="autocomplete-list"></div>
<!-- Sets for current exercise (shown after name is entered) -->
<div id="sets-section" class="sets-section hidden">
<div class="sets-header">
<div class="section-label" id="sets-label">Sets</div>
<button id="btn-delete-exercise" class="btn-link btn-danger hidden">Remove exercise</button>
</div>
<div id="sets-list"></div>
<div class="set-input-row">
<input type="text" id="inp-reps" class="input input-small" placeholder="Reps" inputmode="numeric" pattern="[0-9]*" />
<span class="set-separator">x</span>
<input type="text" id="inp-weight" class="input input-small" placeholder="kg" inputmode="decimal" />
<button id="btn-add-set" class="btn-icon" title="Add set">+</button>
</div>
</div>
</div>
<!-- Notes -->
<div id="notes-section" class="hidden">
<textarea id="inp-note" class="input" rows="2" placeholder="Notes (optional)"></textarea>
</div>
<!-- Save workout button -->
<button id="btn-save-workout" class="btn btn-primary hidden">Save Workout</button>
<!-- Raw text collapsible section -->
<details id="raw-details" class="raw-section">
<summary class="raw-toggle">Paste as text</summary>
<div class="card raw-card">
<textarea id="inp-raw" class="input" rows="6"
placeholder="Bench press: 4x8x35&#10;Shoulder press (3032): 8x25, 5x35&#10;&#10;Squats: 5x5x30"></textarea>
<div class="hint">Same format as the bot. Blank line = new group.</div>
<button id="btn-save-raw" class="btn btn-primary">Save Workout</button>
</div>
</details>
</div>
<!-- ═══ HISTORY VIEW ═══ -->
<div id="view-history" class="view">
<div id="history-list"></div>
<div id="no-history" class="empty-state">
<div class="empty-icon">📋</div>
<div class="empty-icon">&#x1F4CB;</div>
<p>No workouts yet</p>
</div>
<button id="btn-load-more" class="btn btn-secondary" style="display:none">Load more</button>
@ -44,8 +84,8 @@
<!-- ═══ STATS VIEW ═══ -->
<div id="view-stats" class="view">
<div id="stats-content" class="empty-state">
<div class="empty-icon">📊</div>
<p>Loading</p>
<div class="empty-icon">&#x1F4CA;</div>
<p>Loading...</p>
</div>
</div>
</div>

View file

@ -14,6 +14,8 @@ body {
-webkit-font-smoothing: antialiased;
}
.hidden { display: none !important; }
/* ── Tabs ────────────────────────────────────────────────────── */
#tabs {
@ -86,6 +88,46 @@ body {
border: 1.5px solid var(--tg-theme-button-color, #3390ec);
}
.btn-icon {
width: 44px;
height: 44px;
border-radius: 10px;
border: none;
background: var(--tg-theme-button-color, #3390ec);
color: var(--tg-theme-button-text-color, #fff);
font-size: 22px;
font-weight: 600;
cursor: pointer;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
transition: opacity 0.15s;
}
.btn-icon:active { opacity: 0.7; }
.btn-link {
background: none;
border: none;
color: var(--tg-theme-link-color, #3390ec);
font-size: 13px;
cursor: pointer;
padding: 8px 0;
}
.btn-remove {
background: none;
border: none;
color: var(--tg-theme-hint-color, #999);
font-size: 20px;
cursor: pointer;
padding: 0 4px;
line-height: 1;
}
.btn-remove:active { opacity: 0.5; }
/* ── Inputs ──────────────────────────────────────────────────── */
.input {
@ -106,6 +148,12 @@ body {
border-color: var(--tg-theme-button-color, #3390ec);
}
.input-small {
width: auto;
flex: 1;
min-width: 0;
}
.hint {
font-size: 12px;
color: var(--tg-theme-hint-color, #999);
@ -113,35 +161,191 @@ body {
line-height: 1.4;
}
/* ── Suggestion chips ────────────────────────────────────────── */
/* ── Structured log: exercise name row ──────────────────────── */
.section-label {
font-size: 12px;
font-weight: 600;
.exercise-name-row {
display: flex;
gap: 8px;
margin-bottom: 4px;
}
.exercise-name-row .input { flex: 1; }
/* ── Autocomplete ────────────────────────────────────────────── */
.autocomplete-list {
border-radius: 10px;
overflow: hidden;
max-height: 0;
opacity: 0;
transition: max-height 0.2s, opacity 0.2s;
}
.autocomplete-list.visible {
max-height: 280px;
opacity: 1;
margin-top: 4px;
border: 1px solid var(--tg-theme-hint-color, #999)33;
}
.autocomplete-item {
padding: 10px 12px;
font-size: 14px;
cursor: pointer;
background: var(--tg-theme-bg-color, #fff);
border-bottom: 1px solid var(--tg-theme-hint-color, #999)22;
}
.autocomplete-item:last-child { border-bottom: none; }
.autocomplete-item:active { background: var(--tg-theme-secondary-bg-color, #f0f0f0); }
/* ── Sets section ────────────────────────────────────────────── */
.sets-section {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--tg-theme-hint-color, #999)22;
}
.sets-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.btn-danger {
color: #e53935 !important;
font-size: 12px !important;
}
.set-input-row {
display: flex;
gap: 8px;
align-items: center;
margin-top: 8px;
}
.set-separator {
color: var(--tg-theme-hint-color, #999);
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 600;
font-size: 16px;
}
.set-entry {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
border-bottom: 1px solid var(--tg-theme-hint-color, #999)15;
}
.set-text {
font-size: 14px;
font-weight: 500;
}
/* ── Exercise cards (workout preview) ────────────────────────── */
.exercise-card {
background: var(--tg-theme-secondary-bg-color, #f0f0f0);
border-radius: 12px;
padding: 12px 16px;
margin-bottom: 8px;
}
#suggestion-chips {
.exercise-card-header {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 16px;
justify-content: space-between;
align-items: center;
}
.chip {
padding: 6px 12px;
border-radius: 16px;
.exercise-card-actions {
display: flex;
gap: 4px;
}
.btn-edit {
font-size: 14px !important;
}
.exercise-card-name {
font-weight: 600;
font-size: 15px;
}
.exercise-card-sets {
font-size: 13px;
background: var(--tg-theme-secondary-bg-color, #f0f0f0);
color: var(--tg-theme-text-color, #000);
border: 1px solid var(--tg-theme-hint-color, #999)33;
cursor: pointer;
color: var(--tg-theme-hint-color, #999);
margin-top: 4px;
}
.chip:active { opacity: 0.7; }
/* ── Editing banner ──────────────────────────────────────────── */
.editing-banner {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 16px;
margin-bottom: 12px;
border-radius: 10px;
background: var(--tg-theme-button-color, #3390ec)15;
border: 1px solid var(--tg-theme-button-color, #3390ec)40;
font-size: 14px;
font-weight: 600;
color: var(--tg-theme-button-color, #3390ec);
}
/* ── Notes ───────────────────────────────────────────────────── */
#notes-section {
margin-bottom: 12px;
}
#notes-section .input {
font-size: 14px;
resize: none;
}
/* ── Raw text collapsible ─────────────────────────────────────── */
.raw-section {
margin-top: 8px;
}
.raw-toggle {
font-size: 13px;
color: var(--tg-theme-link-color, #3390ec);
cursor: pointer;
padding: 8px 0;
list-style: none;
text-align: center;
}
.raw-toggle::-webkit-details-marker { display: none; }
.raw-toggle::before {
content: "\25B6 ";
font-size: 10px;
transition: transform 0.2s;
display: inline-block;
}
details[open] .raw-toggle::before {
transform: rotate(90deg);
}
.raw-card {
margin-top: 8px;
}
/* ── Section labels ──────────────────────────────────────────── */
.section-label {
font-size: 13px;
font-weight: 600;
color: var(--tg-theme-button-color, #3390ec);
margin-bottom: 8px;
}
/* ── History cards ───────────────────────────────────────────── */
@ -164,12 +368,22 @@ body {
font-weight: 600;
}
.history-header-right {
display: flex;
align-items: center;
gap: 8px;
}
.history-volume {
font-size: 13px;
font-weight: 600;
color: var(--tg-theme-button-color, #3390ec);
}
.btn-history-edit {
font-size: 14px !important;
}
.history-group {
margin-bottom: 8px;
}
@ -233,7 +447,6 @@ body {
/* ── Empty state ─────────────────────────────────────────────── */
.empty-state { text-align: center; padding: 48px 16px; }
.empty-icon { font-size: 48px; margin-bottom: 12px; }
.empty-state p { color: var(--tg-theme-hint-color, #999); font-size: 16px; }
/* ── Toast ───────────────────────────────────────────────────── */