const Modal = { show(html) { const container = document.getElementById('modal-container'); container.innerHTML = ``; }, close() { document.getElementById('modal-container').innerHTML = ''; }, closeOnBg(e) { if (e.target.classList.contains('modal-overlay')) this.close(); }, // ============================================================ // Универсальный степпер // ============================================================ stepperHtml(id, value, step, placeholder, inputmode = 'decimal') { const displayValue = value !== '' && value !== null && value !== undefined ? value : ''; return `
`; }, stepValue(id, delta) { const input = document.getElementById(id); if (!input) return; const current = parseFloat(input.value) || 0; const newVal = Math.max(0, current + delta); // Округляем до шага, чтобы избежать 0.30000000004 const step = parseFloat(input.step) || 1; const rounded = Math.round(newVal / step) * step; input.value = parseFloat(rounded.toFixed(4)); this._onStepperChange(id); }, _onStepperChange(id) { // Хук для подклассов модалок — переопределяется при необходимости if (this._stepperChangeHook) { this._stepperChangeHook(id); } }, // ============================================================ // Dialog: Упражнение // ============================================================ showExerciseDialog(workoutType, callback) { const nameCount = {}; App.state.exerciseRef.forEach(ex => { const isMatch = workoutType && ex.workoutType === workoutType; const bonus = isMatch ? 100 : 10; nameCount[ex.name] = (nameCount[ex.name] || 0) + bonus; }); App.state.workouts.forEach(w => { w.exercises.forEach(ex => { nameCount[ex.name] = (nameCount[ex.name] || 0) + 1; }); }); const sortedNames = Object.keys(nameCount).sort((a, b) => { const diff = nameCount[b] - nameCount[a]; if (diff !== 0) return diff; return a.localeCompare(b); }); this.show(` `); this._exerciseCb = callback; this._exerciseNames = sortedNames; setTimeout(() => { document.getElementById('m-ex-name').focus(); this._filterExercises(); }, 50); }, _filterExercises() { const input = document.getElementById('m-ex-name'); const list = document.getElementById('m-ex-autocomplete'); const query = input.value.trim().toLowerCase(); let filtered = this._exerciseNames; if (query) { filtered = filtered.filter(n => n.toLowerCase().includes(query)); } if (filtered.length === 0) { list.innerHTML = '
Нет совпадений — можно ввести новое
'; return; } list.innerHTML = filtered.slice(0, 15).map(name => { const count = App.state.workouts.reduce((sum, w) => sum + w.exercises.filter(e => e.name === name).length, 0 ); return `
${name} ${count > 0 ? `${count}×` : ''}
`; }).join(''); }, _selectExercise(name) { document.getElementById('m-ex-name').value = name; document.getElementById('m-ex-autocomplete').innerHTML = ''; }, _submitExercise() { const name = document.getElementById('m-ex-name').value.trim(); this.close(); this._exerciseCb(name); }, // ============================================================ // Dialog: Подход // ============================================================ showSetDialog(existingSet, history, callback) { const isEdit = !!existingSet; const isDropset = existingSet?.isDropset || false; let historyHtml = ''; if (history && history.length > 0) { const rows = history.map(h => { const date = new Date(h.date).toLocaleDateString('ru-RU', {day:'2-digit', month:'2-digit'}); const setsStr = h.sets.map(s => { if (s.isDropset && s.subSets) { return s.subSets.map(ss => `${ss.weight}×${ss.reps}`).join('-'); } return `${s.weight}×${s.reps}`; }).join(', '); return `
${date} ${setsStr}
`; }).join(''); historyHtml = `
📊 Прошлые тренировки
${rows}
`; } this.show(` `); this._setData = { isDropset: isDropset, weight: existingSet?.weight || '', reps: existingSet?.reps || '', rating: existingSet?.rating || 3, subSets: existingSet?.subSets ? JSON.parse(JSON.stringify(existingSet.subSets)) : [], }; this._setCb = callback; this._stepperChangeHook = null; if (isDropset) { if (this._setData.subSets.length === 0) { this._addSubset(); } else { this._renderSubsets(); } } this._selectRating(this._setData.rating); }, _toggleDropsetMode() { const isDropset = document.getElementById('m-set-dropset').checked; this._setData.isDropset = isDropset; document.getElementById('m-set-normal').classList.toggle('hidden', isDropset); document.getElementById('m-set-dropset-block').classList.toggle('hidden', !isDropset); if (isDropset && this._setData.subSets.length === 0) { this._addSubset(); } else if (isDropset) { this._renderSubsets(); } }, _addSubset() { const last = this._setData.subSets[this._setData.subSets.length - 1]; this._setData.subSets.push({ weight: last ? last.weight : '', reps: last ? last.reps : '', }); this._renderSubsets(); }, _renderSubsets() { const list = document.getElementById('m-subsets-list'); list.innerHTML = this._setData.subSets.map((s, i) => `
${this.stepperHtml(`m-sub-w-${i}`, s.weight, 0.5, 'Вес', 'decimal').replace(/id="m-sub-w-[^"]+"/, `id="m-sub-w-${i}"`)} ${this.stepperHtml(`m-sub-r-${i}`, s.reps, 1, 'Повт.', 'numeric').replace(/id="m-sub-r-[^"]+"/, `id="m-sub-r-${i}"`)}
`).join(''); // Включаем хук для синхронизации с _setData this._stepperChangeHook = (id) => { const wMatch = id.match(/^m-sub-w-(\d+)$/); const rMatch = id.match(/^m-sub-r-(\d+)$/); if (wMatch) { const i = parseInt(wMatch[1]); this._setData.subSets[i].weight = document.getElementById(id).value; } else if (rMatch) { const i = parseInt(rMatch[1]); this._setData.subSets[i].reps = document.getElementById(id).value; } }; }, _removeSubset(i) { this._setData.subSets.splice(i, 1); this._renderSubsets(); }, _selectRating(r) { this._setData.rating = r; document.querySelectorAll('#m-rating-grid .rating-btn').forEach(btn => { btn.classList.toggle('active', parseInt(btn.dataset.rating) === r); }); }, _submitSet() { if (this._setData.isDropset) { // Синхронизируем значения из input в _setData перед сабмитом this._setData.subSets.forEach((s, i) => { const wInput = document.getElementById(`m-sub-w-${i}`); const rInput = document.getElementById(`m-sub-r-${i}`); if (wInput) s.weight = wInput.value; if (rInput) s.reps = rInput.value; }); if (this._setData.subSets.length === 0) { alert('Добавьте хотя бы одно сбрасывание'); return; } const result = { isDropset: true, subSets: this._setData.subSets.map(s => ({ weight: parseFloat(s.weight) || 0, reps: parseInt(s.reps) || 0, })), rating: this._setData.rating, }; this.close(); this._setCb(result); } else { const weight = parseFloat(document.getElementById('m-set-weight').value); const reps = parseInt(document.getElementById('m-set-reps').value); if (!weight && !reps) { alert('Укажите вес и повторения'); return; } const result = { isDropset: false, weight: weight || 0, reps: reps || 0, rating: this._setData.rating, }; this.close(); this._setCb(result); } }, // ============================================================ // Dialog: Кардио (со степперами) // ============================================================ showCardioDialog(existing, callback) { const isEdit = !!existing; this.show(` `); this._cardioCb = callback; }, _submitCardio() { const result = { type: document.getElementById('m-cardio-type').value, purpose: document.getElementById('m-cardio-purpose').value, duration: parseInt(document.getElementById('m-cardio-duration').value) || 0, distance: parseFloat(document.getElementById('m-cardio-distance').value) || 0, speed: parseFloat(document.getElementById('m-cardio-speed').value) || 0, incline: parseFloat(document.getElementById('m-cardio-incline').value) || 0, resistance: parseInt(document.getElementById('m-cardio-resistance').value) || 0, notes: document.getElementById('m-cardio-notes').value.trim(), }; this.close(); this._cardioCb(result); }, // ============================================================ // Dialog: Замеры тела (со степперами + дата) // ============================================================ showBodyMetricsDialog(existingMetrics, callback, editingMetric = null) { const source = editingMetric || existingMetrics[0]; const isEdit = !!editingMetric; // Форматируем дату для datetime-local let dateValue = ''; if (source?.date) { const d = new Date(source.date); // Формат YYYY-MM-DDTHH:MM const pad = n => String(n).padStart(2, '0'); dateValue = `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; } else if (!isEdit) { // По умолчанию — сейчас const d = new Date(); const pad = n => String(n).padStart(2, '0'); dateValue = `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; } this.show(` `); this._metricsCb = callback; }, _submitMetrics(isEdit) { const dateStr = document.getElementById('m-metric-date').value; const result = { date: dateStr ? new Date(dateStr).toISOString() : new Date().toISOString(), weight: parseFloat(document.getElementById('m-metric-weight').value) || null, chest: parseInt(document.getElementById('m-metric-chest').value) || null, waist: parseInt(document.getElementById('m-metric-waist').value) || null, hips: parseInt(document.getElementById('m-metric-hips').value) || null, arms: parseFloat(document.getElementById('m-metric-arms').value) || null, legs: parseFloat(document.getElementById('m-metric-legs').value) || null, }; if (!Object.values(result).some((v, i) => i > 0 && v !== null)) { alert('Заполните хотя бы одно поле'); return; } this.close(); this._metricsCb(result, isEdit); }, // ============================================================ // Dialog: Редактирование упражнения из справочника // ============================================================ showEditExerciseDialog(exercise, callback) { this.show(` `); this._editExerciseCb = callback; }, _submitEditExercise() { const name = document.getElementById('m-edit-ex-name').value.trim(); const type = document.getElementById('m-edit-ex-type').value; if (!name) { alert('Название не может быть пустым'); return; } this.close(); this._editExerciseCb({ name, workoutType: type }); }, // ============================================================ // Dialog: Редактирование даты тренировки // ============================================================ showEditWorkoutDateDialog(workout, callback) { const start = new Date(workout.startTime); const pad = n => String(n).padStart(2, '0'); const startValue = `${start.getFullYear()}-${pad(start.getMonth()+1)}-${pad(start.getDate())}T${pad(start.getHours())}:${pad(start.getMinutes())}`; let endValue = ''; if (workout.endTime) { const end = new Date(workout.endTime); endValue = `${end.getFullYear()}-${pad(end.getMonth()+1)}-${pad(end.getDate())}T${pad(end.getHours())}:${pad(end.getMinutes())}`; } this.show(` `); this._workoutDateCb = callback; }, _submitWorkoutDate() { const startStr = document.getElementById('m-w-start').value; const endStr = document.getElementById('m-w-end').value; if (!startStr) { alert('Укажите дату начала'); return; } this.close(); this._workoutDateCb({ startTime: new Date(startStr).toISOString(), endTime: endStr ? new Date(endStr).toISOString() : null, }); }, };