// ============================================================ // STATE // ============================================================ const STORAGE_KEY = 'gym_tracker_v4'; const App = { state: { workouts: [], exerciseRef: [], // [{id, name, workoutType}] bodyMetrics: [], expandedWorkoutId: null, expandedExerciseId: null, nextId: 1, }, init() { this.load(); this.render(); }, load() { const saved = localStorage.getItem(STORAGE_KEY); if (saved) { try { this.state = JSON.parse(saved); if (!this.state.exerciseRef) this.state.exerciseRef = []; if (!this.state.bodyMetrics) this.state.bodyMetrics = []; } catch (e) { console.error('Failed to load state', e); } } }, save() { localStorage.setItem(STORAGE_KEY, JSON.stringify(this.state)); this.updateSyncStatus(); }, genId() { return (this.state.nextId++).toString(); }, // ============================================================ // SYNC // ============================================================ getDirtyWorkouts() { return this.state.workouts.filter(w => !w.synced); }, updateSyncStatus() { const dirty = this.getDirtyWorkouts(); const el = document.getElementById('sync-status'); if (dirty.length === 0) { el.textContent = '● Всё синхронизировано'; el.className = 'sync-status synced'; } else { el.textContent = `● ${dirty.length} не сохран.`; el.className = 'sync-status pending'; } }, async sync() { const dirty = this.getDirtyWorkouts(); if (dirty.length === 0) { alert('Нет данных для синхронизации'); return; } console.log('Syncing:', JSON.stringify(dirty, null, 2)); dirty.forEach(w => w.synced = true); this.save(); this.render(); alert(`Отправлено тренировок: ${dirty.length}`); }, // ============================================================ // WORKOUT CRUD // ============================================================ startWorkout() { const w = { id: this.genId(), startTime: new Date().toISOString(), endTime: null, type: '', exercises: [], cardio: [], notes: '', synced: false, }; this.state.workouts.unshift(w); this.state.expandedWorkoutId = w.id; this.save(); this.render(); }, finishWorkout(workoutId) { if (!confirm('Завершить тренировку?')) return; const w = this.getWorkout(workoutId); w.endTime = new Date().toISOString(); this.save(); this.render(); }, deleteWorkout(workoutId) { if (!confirm('Удалить тренировку полностью?')) return; this.state.workouts = this.state.workouts.filter(w => w.id !== workoutId); this.save(); this.render(); }, toggleWorkout(workoutId) { this.state.expandedWorkoutId = this.state.expandedWorkoutId === workoutId ? null : workoutId; this.render(); }, getWorkout(id) { return this.state.workouts.find(w => w.id === id); }, getActiveWorkout() { return this.state.workouts.find(w => !w.endTime); }, editWorkoutDate(workoutId) { const w = this.getWorkout(workoutId); if (!w) return; Modal.showEditWorkoutDateDialog(w, (newDates) => { if (!newDates) return; w.startTime = newDates.startTime; w.endTime = newDates.endTime; this.save(); this.render(); }); }, // ============================================================ // EXERCISE CRUD // ============================================================ addExercise(workoutId) { const w = this.getWorkout(workoutId); Modal.showExerciseDialog(w.type, (name) => { if (!name) return; const ex = { id: this.genId(), name: name, sets: [], }; w.exercises.push(ex); this._addToExerciseRef(name, w.type); this.state.expandedExerciseId = ex.id; this.save(); this.render(); }); }, _addToExerciseRef(name, workoutType = '') { const normalizedName = name.trim().toLowerCase(); const exists = this.state.exerciseRef.find( e => e.name.trim().toLowerCase() === normalizedName ); if (!exists) { this.state.exerciseRef.push({ id: this.genId(), name: name.trim(), workoutType: workoutType, }); } else if (!exists.workoutType && workoutType) { // Обновляем тип, если он был пуст exists.workoutType = workoutType; } }, toggleExercise(exerciseId) { this.state.expandedExerciseId = this.state.expandedExerciseId === exerciseId ? null : exerciseId; this.render(); }, deleteExercise(workoutId, exerciseId) { if (!confirm('Удалить упражнение?')) return; const w = this.getWorkout(workoutId); w.exercises = w.exercises.filter(e => e.id !== exerciseId); this.save(); this.render(); }, getExerciseHistory(exerciseName) { const normalizedName = exerciseName.trim().toLowerCase(); const history = []; for (const w of this.state.workouts) { for (const ex of w.exercises) { if (ex.name.trim().toLowerCase() === normalizedName && ex.sets.length > 0) { history.push({ date: w.startTime, sets: ex.sets, }); } } } history.sort((a, b) => new Date(b.date) - new Date(a.date)); return history.slice(0, 3); }, // ============================================================ // SET CRUD // ============================================================ addSet(workoutId, exerciseId) { const w = this.getWorkout(workoutId); const ex = w.exercises.find(e => e.id === exerciseId); const lastSet = ex.sets[ex.sets.length - 1]; const history = this.getExerciseHistory(ex.name); Modal.showSetDialog(lastSet, history, (setData) => { if (!setData) return; setData.id = this.genId(); setData.timestamp = new Date().toISOString(); ex.sets.push(setData); this.save(); this.render(); }); }, editSet(workoutId, exerciseId, setId) { const w = this.getWorkout(workoutId); const ex = w.exercises.find(e => e.id === exerciseId); const set = ex.sets.find(s => s.id === setId); const history = this.getExerciseHistory(ex.name); Modal.showSetDialog(set, history, (setData) => { if (!setData) return; Object.assign(set, setData); this.save(); this.render(); }); }, deleteSet(workoutId, exerciseId, setId) { if (!confirm('Удалить подход?')) return; const w = this.getWorkout(workoutId); const ex = w.exercises.find(e => e.id === exerciseId); ex.sets = ex.sets.filter(s => s.id !== setId); this.save(); this.render(); }, // ============================================================ // CARDIO CRUD // ============================================================ addCardio(workoutId) { Modal.showCardioDialog(null, (cardio) => { if (!cardio) return; const w = this.getWorkout(workoutId); cardio.id = this.genId(); w.cardio.push(cardio); this.save(); this.render(); }); }, editCardio(workoutId, cardioId) { const w = this.getWorkout(workoutId); const cardio = w.cardio.find(c => c.id === cardioId); Modal.showCardioDialog(cardio, (newData) => { if (!newData) return; Object.assign(cardio, newData); this.save(); this.render(); }); }, deleteCardio(workoutId, cardioId) { if (!confirm('Удалить кардио?')) return; const w = this.getWorkout(workoutId); w.cardio = w.cardio.filter(c => c.id !== cardioId); this.save(); this.render(); }, // ============================================================ // BODY METRICS // ============================================================ showBodyMetrics() { Modal.showBodyMetricsDialog(this.state.bodyMetrics, (newMetric, isEdit) => { if (!newMetric) return; if (isEdit) { // При редактировании — ищем по временной метке исходного замера // (передаём через замыкание) return; } newMetric.id = this.genId(); this.state.bodyMetrics.unshift(newMetric); // Сортируем по дате (новые сверху) this.state.bodyMetrics.sort((a, b) => new Date(b.date) - new Date(a.date)); this.save(); this.render(); alert('Замеры сохранены!'); }); }, editBodyMetric(metricId) { const metric = this.state.bodyMetrics.find(m => m.id === metricId); if (!metric) return; Modal.showBodyMetricsDialog(this.state.bodyMetrics, (updatedData, isEdit) => { if (!updatedData) return; Object.assign(metric, updatedData); this.state.bodyMetrics.sort((a, b) => new Date(b.date) - new Date(a.date)); this.save(); this._renderBodyMetricsList(); }, metric); // передаём редактируемый замер }, // ============================================================ // SETTINGS // ============================================================ showSettings() { const app = document.getElementById('app'); app.innerHTML = ''; const screen = document.createElement('div'); screen.className = 'settings-screen'; screen.innerHTML = `

⚙️ Настройки

📋 Справочник упражнений

Управляйте списком упражнений и привязывайте их к типам тренировок.

📏 Замеры тела

🗑 Данные

`; app.appendChild(screen); this._renderRefExercises(); this._renderBodyMetricsList(); }, addRefExercise() { const input = document.getElementById('new-ref-exercise'); const typeSelect = document.getElementById('new-ref-type'); const name = input.value.trim(); if (!name) return; this._addToExerciseRef(name, typeSelect.value); this.save(); input.value = ''; typeSelect.value = ''; this._renderRefExercises(); }, deleteRefExercise(id) { if (!confirm('Удалить из справочника? (в тренировках останется)')) return; this.state.exerciseRef = this.state.exerciseRef.filter(e => e.id !== id); this.save(); this._renderRefExercises(); }, updateRefExerciseType(id, newType) { const ex = this.state.exerciseRef.find(e => e.id === id); if (ex) { ex.workoutType = newType; this.save(); } }, editRefExercise(id) { const ex = this.state.exerciseRef.find(e => e.id === id); if (!ex) return; Modal.showEditExerciseDialog(ex, (updatedData) => { if (!updatedData) return; Object.assign(ex, updatedData); this.save(); this._renderRefExercises(); }); }, _renderRefExercises() { const list = document.getElementById('ref-exercises-list'); if (!list) return; if (this.state.exerciseRef.length === 0) { list.innerHTML = '

Справочник пуст

'; return; } const sorted = [...this.state.exerciseRef].sort((a, b) => { if (a.workoutType !== b.workoutType) { return (a.workoutType || '').localeCompare(b.workoutType || ''); } return a.name.localeCompare(b.name); }); list.innerHTML = sorted.map(ex => { let count = 0; this.state.workouts.forEach(w => { w.exercises.forEach(e => { if (e.name.trim().toLowerCase() === ex.name.trim().toLowerCase()) { count++; } }); }); return `
${ex.name} (${count}×) ${ex.workoutType ? `${ex.workoutType}` : ''}
`; }).join(''); }, _renderBodyMetricsList() { const list = document.getElementById('body-metrics-list'); if (!list) return; if (this.state.bodyMetrics.length === 0) { list.innerHTML = '

Нет замеров

'; return; } list.innerHTML = this.state.bodyMetrics.map(m => { const date = new Date(m.date); const dateStr = date.toLocaleDateString('ru-RU') + ' ' + date.toLocaleTimeString('ru-RU', {hour:'2-digit', minute:'2-digit'}); const parts = []; if (m.weight) parts.push(`⚖️ ${m.weight} кг`); if (m.chest) parts.push(`📏 ${m.chest} см`); if (m.waist) parts.push(`📏 ${m.waist} см`); if (m.hips) parts.push(`📏 ${m.hips} см`); if (m.arms) parts.push(`💪 ${m.arms} см`); if (m.legs) parts.push(`🦵 ${m.legs} см`); return `
${dateStr}
${parts.join(' • ') || '—'}
`; }).join(''); }, deleteMetric(id) { if (!confirm('Удалить замер?')) return; this.state.bodyMetrics = this.state.bodyMetrics.filter(m => m.id !== id); this.save(); this._renderBodyMetricsList(); }, clearAllData() { if (!confirm('Удалить ВСЕ данные? Это действие необратимо!')) return; if (!confirm('Точно уверены?')) return; localStorage.removeItem(STORAGE_KEY); location.reload(); }, // ============================================================ // RENDER // ============================================================ render() { const app = document.getElementById('app'); app.innerHTML = ''; if (this.state.workouts.length === 0) { app.innerHTML = `

🏋️ Нет тренировок

Начните первую тренировку

`; return; } const sorted = [...this.state.workouts].sort((a, b) => { if (!a.endTime && b.endTime) return -1; if (a.endTime && !b.endTime) return 1; return new Date(b.startTime) - new Date(a.startTime); }); if (!this.getActiveWorkout()) { const btn = document.createElement('button'); btn.className = 'btn mb-10'; btn.textContent = '▶ Начать новую тренировку'; btn.onclick = () => this.startWorkout(); app.appendChild(btn); } sorted.forEach(w => { app.appendChild(this.renderWorkoutItem(w)); }); this.updateSyncStatus(); }, renderWorkoutItem(w) { const isActive = !w.endTime; const isExpanded = this.state.expandedWorkoutId === w.id; const isReadOnly = w.synced && w.endTime; // синхронизирована и завершена const div = document.createElement('div'); div.className = 'workout-accordion'; const headerClass = `workout-header ${isActive ? 'active' : 'completed'} ${isReadOnly ? 'read-only-overlay' : ''}`; const start = new Date(w.startTime); const dateStr = start.toLocaleDateString('ru-RU') + ' ' + start.toLocaleTimeString('ru-RU', {hour:'2-digit', minute:'2-digit'}); const typeStr = w.type || 'тип не выбран'; const exCount = w.exercises.length; const cardioCount = w.cardio.length; let statusBadge; if (isReadOnly) { statusBadge = '✓ архив'; } else if (w.synced) { statusBadge = '✓'; } else { statusBadge = '● не сохранено'; } const editDateBtn = !isReadOnly ? ` ✎ ` : ''; div.innerHTML = `
${isActive ? '🔥 Тренировка' : '✅ Завершена'}
${dateStr} ${editDateBtn} • ${typeStr}
Упр: ${exCount} • Кардио: ${cardioCount}
${statusBadge} ›
`; const body = document.createElement('div'); body.className = `workout-body ${isExpanded ? 'expanded' : ''}`; if (isExpanded) { body.appendChild(this.renderWorkoutDetails(w)); } div.appendChild(body); return div; }, renderWorkoutDetails(w) { const wrap = document.createElement('div'); const isActive = !w.endTime; const isReadOnly = w.synced && w.endTime; // Баннер read-only if (isReadOnly) { const banner = document.createElement('div'); banner.className = 'read-only-banner'; banner.textContent = '🔒 Тренировка в архиве — только просмотр'; wrap.appendChild(banner); } // Тип тренировки const typeCard = document.createElement('div'); typeCard.className = 'card'; if (isReadOnly) { typeCard.innerHTML = `
Тип тренировки
${w.type || '—'}
`; } else { typeCard.innerHTML = ` `; } wrap.appendChild(typeCard); // Кнопки добавления (только для активной и не read-only) if (isActive && !isReadOnly) { const addSection = document.createElement('div'); addSection.className = 'add-section'; addSection.innerHTML = ` `; wrap.appendChild(addSection); } // === Сортировка кардио: warmup/separate сверху, cooldown внизу === const cardioWarmup = w.cardio.filter(c => c.purpose === 'warmup' || c.purpose === 'separate'); const cardioCooldown = w.cardio.filter(c => c.purpose === 'cooldown'); // Разминка/отдельная — перед упражнениями if (cardioWarmup.length > 0) { const title = document.createElement('h4'); title.style.margin = '16px 0 8px'; title.textContent = '🔥 Разминка / Кардио'; wrap.appendChild(title); cardioWarmup.forEach(c => { wrap.appendChild(this.renderCardioItem(w.id, c, isReadOnly)); }); } // Упражнения const sortedExercises = [...w.exercises].reverse(); sortedExercises.forEach(ex => { wrap.appendChild(this.renderExercise(w, ex, isReadOnly)); }); // Заминка — в самом низу (перед комментарием) if (cardioCooldown.length > 0) { const title = document.createElement('h4'); title.style.margin = '16px 0 8px'; title.textContent = '🧘 Заминка'; wrap.appendChild(title); cardioCooldown.forEach(c => { wrap.appendChild(this.renderCardioItem(w.id, c, isReadOnly)); }); } // Отдельное кардио (если было — уже показано в разминке, но на всякий случай) // (уже обработано выше) // Комментарий const notesCard = document.createElement('div'); notesCard.className = 'card mt-10'; if (isReadOnly) { notesCard.innerHTML = `
Комментарий
${w.notes || '—'}
`; } else { notesCard.innerHTML = ` `; notesCard.querySelector('textarea').oninput = (e) => { w.notes = e.target.value; this.save(); }; } wrap.appendChild(notesCard); // Блок завершения / удаления const finishBlock = document.createElement('div'); finishBlock.className = 'finish-block'; if (isReadOnly) { finishBlock.innerHTML = `

🔒 Архив

Тренировка синхронизирована и завершена. Редактирование недоступно.

`; } else if (isActive) { finishBlock.innerHTML = `

Завершение тренировки

После завершения данные останутся локально до синхронизации

`; } else { finishBlock.innerHTML = `

Тренировка завершена

До синхронизации можно редактировать

`; } wrap.appendChild(finishBlock); return wrap; }, renderExercise(w, ex, isReadOnly = false) { const isExpanded = this.state.expandedExerciseId === ex.id; const totalSets = ex.sets.length; const summary = totalSets > 0 ? `${totalSets} подх.` : ''; const card = document.createElement('div'); card.className = 'exercise-card'; card.innerHTML = `
${ex.name} ${summary}
›
`; const body = document.createElement('div'); body.className = `exercise-body ${isExpanded ? 'expanded' : ''}`; if (isExpanded) { if (ex.sets.length > 0) { const table = document.createElement('div'); table.className = 'sets-table'; [...ex.sets].reverse().forEach((set, idx) => { const realIdx = ex.sets.length - 1 - idx; table.appendChild(this.renderSetRow(w.id, ex.id, set, realIdx, isReadOnly)); }); body.appendChild(table); } else { body.innerHTML += `

Пока нет подходов

`; } if (!isReadOnly) { if (!w.endTime) { const addBtn = document.createElement('button'); addBtn.className = 'btn mt-10'; addBtn.textContent = '+ Добавить подход'; addBtn.onclick = () => this.addSet(w.id, ex.id); body.appendChild(addBtn); } const delBtn = document.createElement('button'); delBtn.className = 'btn btn-ghost mt-10'; delBtn.style.width = '100%'; delBtn.textContent = 'Удалить упражнение'; delBtn.onclick = (e) => { e.stopPropagation(); this.deleteExercise(w.id, ex.id); }; body.appendChild(delBtn); } } card.appendChild(body); return card; }, renderSetRow(workoutId, exerciseId, set, index, isReadOnly = false) { const row = document.createElement('div'); const actionsHtml = isReadOnly ? '' : `
`; if (set.isDropset && set.subSets && set.subSets.length > 0) { row.className = 'dropset-group'; const subsText = set.subSets.map(s => `${s.weight}×${s.reps}`).join(' → '); const ratingStars = '★'.repeat(set.rating || 3); row.innerHTML = `
${index + 1}
🔥 ${subsText}
${ratingStars}
${actionsHtml}
`; } else { row.innerHTML = `
${index + 1}
${set.weight || '—'} кг
${set.reps || '—'}
${'★'.repeat(set.rating || 3)}
${actionsHtml}
`; } return row; }, renderCardioItem(workoutId, c, isReadOnly = false) { const card = document.createElement('div'); card.className = 'card'; const purposeLabels = { warmup: '🔥 Разминка', cooldown: '🧘 Заминка', separate: '➕ Отдельная' }; const metrics = []; if (c.duration) metrics.push(`${c.duration} мин`); if (c.distance) metrics.push(`${c.distance} км`); if (c.speed) metrics.push(`${c.speed} км/ч`); if (c.incline) metrics.push(`наклон ${c.incline}`); if (c.resistance) metrics.push(`сопр. ${c.resistance}`); const actionsHtml = isReadOnly ? '' : `
`; card.innerHTML = `
${c.type || '—'} ${purposeLabels[c.purpose] || ''}
${metrics.join(' • ') || '—'}
${c.notes ? `
"${c.notes}"
` : ''}
${actionsHtml}
`; return card; }, }; App.init();