commit e05ff1449b3c4bcce8d24ef50d1c1931967d3d06 Author: Wayne H. Shephard Date: Tue Sep 29 01:29:22 2026 +0300 initial commit (ui render and basic logic) diff --git a/app.js b/app.js new file mode 100644 index 0000000..8ce2cf9 --- /dev/null +++ b/app.js @@ -0,0 +1,864 @@ +// ============================================================ +// 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(); diff --git a/index.html b/index.html new file mode 100644 index 0000000..3a2b0ad --- /dev/null +++ b/index.html @@ -0,0 +1,30 @@ + + + + + + Gym Tracker + + + + +
+
+

Gym Tracker

+
● Локально
+
+
+ + + +
+
+ +
+ + + + + + + diff --git a/modal.js b/modal.js new file mode 100644 index 0000000..6ae068a --- /dev/null +++ b/modal.js @@ -0,0 +1,602 @@ +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, + }); + }, +}; diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..7d38a57 --- /dev/null +++ b/styles.css @@ -0,0 +1,571 @@ +:root { + --bg: #121212; + --surface: #1e1e1e; + --surface-2: #2c2c2c; + --primary: #bb86fc; + --primary-variant: #3700b3; + --text: #ffffff; + --text-secondary: #b0b0b0; + --success: #03dac6; + --danger: #cf6679; + --warning: #fbbc04; + --border: #333333; + --radius: 10px; +} + +* { + box-sizing: border-box; + touch-action: manipulation; + -webkit-tap-highlight-color: transparent; +} + +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + background-color: var(--bg); + color: var(--text); + padding-bottom: 40px; +} + +header { + background-color: var(--surface); + padding: 14px 16px; + position: sticky; + top: 0; + z-index: 50; + border-bottom: 1px solid var(--border); + display: flex; + justify-content: space-between; + align-items: center; +} + +h1, h2, h3, h4 { margin: 0; } +h2 { font-size: 1.15rem; } +h3 { font-size: 1.05rem; } + +.container { padding: 12px; } + +.header-actions { display: flex; gap: 8px; align-items: center; } +.header-btn { font-size: 1.3rem; padding: 8px 10px; } + +/* === Buttons === */ +.btn { + background-color: var(--primary); + color: #000; + border: none; + border-radius: var(--radius); + padding: 14px 20px; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + width: 100%; + text-align: center; + transition: opacity 0.15s; +} +.btn:active { opacity: 0.7; } +.btn:disabled { opacity: 0.4; cursor: not-allowed; } +.btn-secondary { + background-color: var(--surface-2); + color: var(--text); + border: 1px solid var(--border); +} +.btn-danger { background-color: var(--danger); color: #fff; } +.btn-success { background-color: var(--success); color: #000; } +.btn-small { width: auto; padding: 8px 14px; font-size: 0.9rem; } +.btn-ghost { + background: transparent; + border: 1px solid var(--border); + color: var(--text-secondary); + padding: 6px 10px; + font-size: 0.85rem; + width: auto; + border-radius: 6px; +} +.btn-row { display: flex; gap: 8px; margin-top: 10px; } +.btn-row .btn { flex: 1; } + +/* === Cards === */ +.card { + background-color: var(--surface); + border-radius: var(--radius); + padding: 14px; + margin-bottom: 12px; + border: 1px solid var(--border); +} + +/* === Sync status === */ +.sync-status { + font-size: 0.75rem; + color: var(--text-secondary); + display: flex; + align-items: center; + gap: 5px; + margin-top: 2px; +} +.sync-status.synced { color: var(--success); } +.sync-status.pending { color: var(--warning); } + +/* === Inputs === */ +input, select, textarea { + background-color: var(--surface-2); + border: 1px solid var(--border); + color: var(--text); + border-radius: 8px; + padding: 13px; + font-size: 1rem; + width: 100%; + font-family: inherit; +} +input:focus, select:focus, textarea:focus { + outline: 2px solid var(--primary); + border-color: transparent; +} +input[type="number"] { text-align: center; font-weight: 600; } +textarea { resize: vertical; min-height: 60px; } + +/* === Accordion (workout list) === */ +.workout-accordion { + margin-bottom: 10px; + border-radius: var(--radius); + overflow: hidden; + border: 1px solid var(--border); +} +.workout-header { + background-color: var(--surface); + padding: 14px; + display: flex; + justify-content: space-between; + align-items: center; + cursor: pointer; +} +.workout-header.active { border-left: 4px solid var(--primary); } +.workout-header.completed { opacity: 0.7; } +.workout-meta { + font-size: 0.8rem; + color: var(--text-secondary); + margin-top: 4px; +} +.workout-body { + background-color: var(--bg); + padding: 12px; + display: none; +} +.workout-body.expanded { display: block; } +.workout-badge { + font-size: 0.7rem; + padding: 3px 8px; + border-radius: 4px; + background: var(--surface-2); + color: var(--text-secondary); +} +.workout-badge.dirty { background: var(--warning); color: #000; } +.workout-badge.synced-badge { background: var(--success); color: #000; } + +/* === Exercise accordion === */ +.exercise-card { + background-color: var(--surface); + border-radius: var(--radius); + margin-bottom: 10px; + border: 1px solid var(--border); + overflow: hidden; +} +.exercise-header { + padding: 12px 14px; + display: flex; + justify-content: space-between; + align-items: center; + cursor: pointer; + user-select: none; +} +.exercise-header.expanded { border-bottom: 1px solid var(--border); } +.exercise-name { font-weight: 600; color: var(--primary); font-size: 1rem; } +.exercise-summary { + font-size: 0.8rem; + color: var(--text-secondary); + margin-left: 6px; +} +.chevron { + transition: transform 0.2s; + color: var(--text-secondary); +} +.chevron.open { transform: rotate(90deg); } +.exercise-body { + padding: 12px 14px; + display: none; +} +.exercise-body.expanded { display: block; } + +/* === Set rows === */ +.sets-table { margin-bottom: 12px; } +.set-row { + display: grid; + grid-template-columns: 30px 1fr 1fr auto auto; + gap: 6px; + align-items: center; + padding: 8px 0; + border-bottom: 1px solid var(--border); + font-size: 0.95rem; +} +.set-row:last-child { border-bottom: none; } +.set-number { + color: var(--text-secondary); + font-size: 0.8rem; + text-align: center; +} +.set-cell { text-align: center; font-weight: 600; } +.set-cell.dropset { + color: var(--warning); + font-size: 0.85rem; +} +.set-rating { + color: var(--warning); + font-size: 0.85rem; +} +.set-actions { + display: flex; + gap: 4px; +} +.icon-btn { + background: transparent; + border: none; + color: var(--text-secondary); + padding: 6px 8px; + cursor: pointer; + border-radius: 4px; + font-size: 1rem; +} +.icon-btn:active { background: var(--surface-2); } +.icon-btn.danger { color: var(--danger); } + +/* === Dropset sub-rows === */ +.dropset-group { + background: rgba(251, 188, 4, 0.05); + border-left: 3px solid var(--warning); + padding-left: 8px; + margin: 4px 0; + border-radius: 0 6px 6px 0; +} + +/* === Stepper (ИСПРАВЛЕНО: теперь в строку) === */ +.stepper-row { + display: flex; + gap: 6px; + margin-bottom: 8px; + align-items: center; + flex-wrap: nowrap; +} +.stepper { + display: flex; + align-items: center; + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: 8px; + overflow: hidden; + flex: 1; + min-width: 0; +} +.stepper-btn { + background: transparent; + border: none; + color: var(--primary); + font-size: 1.3rem; + font-weight: bold; + padding: 10px 12px; + cursor: pointer; + flex-shrink: 0; +} +.stepper-btn:active { background: var(--surface); } +.stepper input { + border: none; + background: transparent; + text-align: center; + padding: 10px 4px; + font-size: 1rem; + font-weight: 600; + flex: 1; + min-width: 0; +} +.stepper input:focus { outline: none; } + +/* === Rating buttons === */ +.rating-grid { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 6px; + margin: 10px 0; +} +.rating-btn { + background: var(--surface-2); + border: 1px solid var(--border); + color: var(--text); + border-radius: 8px; + padding: 14px 0; + font-size: 1.1rem; + font-weight: 600; + cursor: pointer; +} +.rating-btn.active { + background-color: var(--warning); + color: #000; + border-color: var(--warning); +} + +/* === History hint === */ +.history-hint { + background: rgba(187, 134, 252, 0.08); + border: 1px dashed var(--primary); + border-radius: 8px; + padding: 10px 12px; + margin-bottom: 14px; + font-size: 0.85rem; + color: var(--text-secondary); + opacity: 0.85; +} +.history-hint-title { + color: var(--primary); + font-weight: 600; + margin-bottom: 6px; + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.5px; +} +.history-hint-row { + display: flex; + justify-content: space-between; + padding: 3px 0; + border-bottom: 1px dotted var(--border); +} +.history-hint-row:last-child { border-bottom: none; } +.history-hint-date { color: var(--text-secondary); font-size: 0.75rem; } +.history-hint-sets { color: var(--text); font-weight: 500; } + +/* === Autocomplete === */ +.autocomplete-list { + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: 8px; + max-height: 200px; + overflow-y: auto; + margin-top: 6px; +} +.autocomplete-item { + padding: 12px 14px; + cursor: pointer; + border-bottom: 1px solid var(--border); + font-size: 0.95rem; +} +.autocomplete-item:last-child { border-bottom: none; } +.autocomplete-item:active { background: var(--surface); } +.autocomplete-item .freq { + float: right; + font-size: 0.75rem; + color: var(--text-secondary); +} + +/* === Finish workout block === */ +.finish-block { + margin-top: 30px; + padding: 16px; + background: linear-gradient(to bottom, transparent, rgba(207, 102, 121, 0.1)); + border-top: 2px solid var(--danger); +} +.finish-block h3 { color: var(--danger); margin-bottom: 10px; } + +/* === Add exercise / cardio buttons === */ +.add-section { + display: flex; + gap: 8px; + margin-bottom: 10px; +} +.add-section .btn { flex: 1; } + +/* === Modal === */ +.modal-overlay { + position: fixed; + top: 0; left: 0; + width: 100%; height: 100%; + background: rgba(0,0,0,0.85); + z-index: 100; + display: flex; + align-items: flex-end; + justify-content: center; + animation: fadeIn 0.2s; +} +.modal-content { + background: var(--surface); + padding: 20px; + border-radius: 16px 16px 0 0; + width: 100%; + max-width: 500px; + max-height: 85vh; + overflow-y: auto; + animation: slideUp 0.25s; +} +@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } +@keyframes slideUp { from { transform: translateY(100%); } to { transform: translateY(0); } } + +.modal-title { margin-bottom: 16px; } +.field-group { margin-bottom: 14px; } +.field-group label { + display: block; + font-size: 0.85rem; + color: var(--text-secondary); + margin-bottom: 6px; +} +.field-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; +} +.field-row-3 { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 8px; +} + +/* === Empty state === */ +.empty-state { + text-align: center; + padding: 40px 20px; + color: var(--text-secondary); +} +.empty-state h3 { color: var(--text); margin-bottom: 10px; } + +/* === Settings screen === */ +.settings-screen { padding: 12px; } +.settings-section { margin-bottom: 24px; } +.settings-section h3 { + color: var(--primary); + margin-bottom: 12px; + padding-bottom: 8px; + border-bottom: 1px solid var(--border); +} +.exercise-ref-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px; + background: var(--surface); + border-radius: 8px; + margin-bottom: 6px; + border: 1px solid var(--border); +} +.exercise-ref-item .name { font-weight: 500; } +.exercise-ref-item .count { + font-size: 0.75rem; + color: var(--text-secondary); + margin-left: 6px; +} +.exercise-ref-item .type-badge { + font-size: 0.7rem; + padding: 2px 6px; + border-radius: 4px; + background: var(--primary-variant); + color: var(--primary); + margin-left: 6px; +} + +/* === Body metrics === */ +.metrics-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; +} +.metric-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 8px; + padding: 12px; + text-align: center; +} +.metric-card .label { + font-size: 0.75rem; + color: var(--text-secondary); + text-transform: uppercase; + margin-bottom: 4px; +} +.metric-card .value { + font-size: 1.3rem; + font-weight: 700; + color: var(--primary); +} +.metric-card .date { + font-size: 0.7rem; + color: var(--text-secondary); + margin-top: 4px; +} + +/* === Universal stepper field === */ +.stepper-field { + display: flex; + align-items: center; + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: 8px; + overflow: hidden; +} +.stepper-field .stepper-btn { + background: transparent; + border: none; + color: var(--primary); + font-size: 1.3rem; + font-weight: bold; + padding: 10px 12px; + cursor: pointer; + flex-shrink: 0; + min-width: 44px; +} +.stepper-field .stepper-btn:active { background: var(--surface); } +.stepper-field input { + border: none; + background: transparent; + text-align: center; + padding: 10px 4px; + font-size: 1rem; + font-weight: 600; + flex: 1; + min-width: 0; +} +.stepper-field input:focus { outline: none; } + +/* === Read-only state === */ +.read-only-overlay { + position: relative; +} +.read-only-banner { + background: var(--success); + color: #000; + padding: 8px 12px; + border-radius: 8px; + font-size: 0.85rem; + font-weight: 600; + text-align: center; + margin-bottom: 12px; +} + +/* === Editable date === */ +.editable-date { + display: inline-flex; + align-items: center; + gap: 4px; + cursor: pointer; + padding: 2px 6px; + border-radius: 4px; +} +.editable-date:hover { background: var(--surface-2); } +.editable-date .edit-icon { + font-size: 0.75rem; + color: var(--text-secondary); + opacity: 0; + transition: opacity 0.15s; +} +.editable-date:hover .edit-icon { opacity: 1; } + +/* === Utility === */ +.hidden { display: none !important; } +.text-muted { color: var(--text-secondary); font-size: 0.85rem; } +.text-small { font-size: 0.8rem; } +.mt-10 { margin-top: 10px; } +.mt-20 { margin-top: 20px; } +.mb-10 { margin-bottom: 10px; } +.divider { height: 1px; background: var(--border); margin: 12px 0; }