From 93ca76c6e1d53ebc1b287ff5e0e2440e70f92cef Mon Sep 17 00:00:00 2001 From: "Wayne H. Shephard" Date: Wed, 30 Sep 2026 01:54:17 +0300 Subject: [PATCH] massive frontend refactoring (works good) --- .gitignore | 4 +- app.js | 864 ------------------ frontend/index.html | 19 + frontend/js/app.js | 51 ++ frontend/js/logic/cardio.js | 35 + frontend/js/logic/exercise.js | 62 ++ frontend/js/logic/metrics.js | 47 + frontend/js/logic/set.js | 45 + frontend/js/logic/settings.js | 185 ++++ frontend/js/logic/workout.js | 66 ++ frontend/js/modals/cardio-modal.js | 57 ++ frontend/js/modals/edit-exercise-modal.js | 42 + frontend/js/modals/edit-workout-date-modal.js | 41 + frontend/js/modals/exercise-modal.js | 60 ++ frontend/js/modals/metrics-modal.js | 44 + frontend/js/modals/modal-base.js | 33 + frontend/js/modals/set-modal.js | 127 +++ frontend/js/render/cardio-card.js | 30 + frontend/js/render/exercise-card.js | 60 ++ frontend/js/render/main-screen.js | 43 + frontend/js/render/set-row.js | 35 + frontend/js/render/workout-card.js | 169 ++++ frontend/js/state.js | 46 + frontend/js/sync.js | 18 + frontend/js/utils.js | 28 + frontend/styles/components.css | 93 ++ frontend/styles/main.css | 57 ++ frontend/styles/modal.css | 25 + frontend/styles/settings.css | 18 + frontend/styles/workout.css | 70 ++ index.html | 30 - modal.js | 602 ------------ schema.sql => sql/schema.sql | 0 seed.sql => sql/seed.sql | 0 verify.sql => sql/verify.sql | 0 styles.css | 571 ------------ 36 files changed, 1608 insertions(+), 2069 deletions(-) delete mode 100644 app.js create mode 100644 frontend/index.html create mode 100644 frontend/js/app.js create mode 100644 frontend/js/logic/cardio.js create mode 100644 frontend/js/logic/exercise.js create mode 100644 frontend/js/logic/metrics.js create mode 100644 frontend/js/logic/set.js create mode 100644 frontend/js/logic/settings.js create mode 100644 frontend/js/logic/workout.js create mode 100644 frontend/js/modals/cardio-modal.js create mode 100644 frontend/js/modals/edit-exercise-modal.js create mode 100644 frontend/js/modals/edit-workout-date-modal.js create mode 100644 frontend/js/modals/exercise-modal.js create mode 100644 frontend/js/modals/metrics-modal.js create mode 100644 frontend/js/modals/modal-base.js create mode 100644 frontend/js/modals/set-modal.js create mode 100644 frontend/js/render/cardio-card.js create mode 100644 frontend/js/render/exercise-card.js create mode 100644 frontend/js/render/main-screen.js create mode 100644 frontend/js/render/set-row.js create mode 100644 frontend/js/render/workout-card.js create mode 100644 frontend/js/state.js create mode 100644 frontend/js/sync.js create mode 100644 frontend/js/utils.js create mode 100644 frontend/styles/components.css create mode 100644 frontend/styles/main.css create mode 100644 frontend/styles/modal.css create mode 100644 frontend/styles/settings.css create mode 100644 frontend/styles/workout.css delete mode 100644 index.html delete mode 100644 modal.js rename schema.sql => sql/schema.sql (100%) rename seed.sql => sql/seed.sql (100%) rename verify.sql => sql/verify.sql (100%) delete mode 100644 styles.css diff --git a/.gitignore b/.gitignore index 7668361..7a60b85 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,2 @@ -**/__pycache__ -**/*.pyc +__pycache__/ +*.pyc diff --git a/app.js b/app.js deleted file mode 100644 index 8ce2cf9..0000000 --- a/app.js +++ /dev/null @@ -1,864 +0,0 @@ -// ============================================================ -// 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/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..a89fd07 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,19 @@ + + + + + + Gym Tracker + + + + + + + +
+
+ + + + diff --git a/frontend/js/app.js b/frontend/js/app.js new file mode 100644 index 0000000..606c4e6 --- /dev/null +++ b/frontend/js/app.js @@ -0,0 +1,51 @@ +import { loadState, saveState, state, updateSyncStatus } from './state.js'; +import { renderMainScreen } from './render/main-screen.js'; +import { sync } from './sync.js'; + +// Подключаем logic-модули (они регистрируются в window) +import './logic/workout.js'; +import './logic/exercise.js'; +import './logic/set.js'; +import './logic/cardio.js'; +import './logic/metrics.js'; +import './logic/settings.js'; + +// Подключаем модалки +import './modals/modal-base.js'; +import './modals/exercise-modal.js'; +import './modals/set-modal.js'; +import './modals/cardio-modal.js'; +import './modals/metrics-modal.js'; +import './modals/edit-exercise-modal.js'; +import './modals/edit-workout-date-modal.js'; + +function renderHeader() { + document.getElementById('app-header').innerHTML = ` +
+

Gym Tracker

+
● Локально
+
+
+ + + +
+ `; +} + +function init() { + loadState(); + renderHeader(); + renderMainScreen(); + updateSyncStatus(); +} + +// Глобальные хелперы для inline-обработчиков +window.App = { + getWorkout: (id) => state.workouts.find(w => w.id === id), + saveAndRender: () => { saveState(); /* перерисовка не нужна — select не требует */ }, +}; +window.AppSync = { sync }; +window.App.backToMain = () => renderMainScreen(); + +document.addEventListener('DOMContentLoaded', init); diff --git a/frontend/js/logic/cardio.js b/frontend/js/logic/cardio.js new file mode 100644 index 0000000..b523728 --- /dev/null +++ b/frontend/js/logic/cardio.js @@ -0,0 +1,35 @@ +import { state, saveState, genId } from '../state.js'; +import { renderMainScreen } from '../render/main-screen.js'; +import { showCardioDialog } from '../modals/cardio-modal.js'; + +export function addCardio(workoutId) { + showCardioDialog(null, (cardio) => { + if (!cardio) return; + const w = state.workouts.find(x => x.id === workoutId); + cardio.id = genId(); + w.cardio.push(cardio); + saveState(); + renderMainScreen(); + }); +} + +export function editCardio(workoutId, cardioId) { + const w = state.workouts.find(x => x.id === workoutId); + const cardio = w.cardio.find(c => c.id === cardioId); + showCardioDialog(cardio, (newData) => { + if (!newData) return; + Object.assign(cardio, newData); + saveState(); + renderMainScreen(); + }); +} + +export function deleteCardio(workoutId, cardioId) { + if (!confirm('Удалить кардио?')) return; + const w = state.workouts.find(x => x.id === workoutId); + w.cardio = w.cardio.filter(c => c.id !== cardioId); + saveState(); + renderMainScreen(); +} + +window.CL = { addCardio, editCardio, deleteCardio }; diff --git a/frontend/js/logic/exercise.js b/frontend/js/logic/exercise.js new file mode 100644 index 0000000..c091d64 --- /dev/null +++ b/frontend/js/logic/exercise.js @@ -0,0 +1,62 @@ +import { state, saveState, genId } from '../state.js'; +import { renderMainScreen } from '../render/main-screen.js'; +import { showExerciseDialog } from '../modals/exercise-modal.js'; + +export function addExercise(workoutId) { + const w = state.workouts.find(x => x.id === workoutId); + showExerciseDialog(w.type, (name) => { + if (!name) return; + const ex = { id: genId(), name, sets: [] }; + w.exercises.push(ex); + addToRef(name, w.type); + state.expandedExerciseId = ex.id; + saveState(); + renderMainScreen(); + }); +} + +export function addToRef(name, workoutType = '') { + const normalizedName = name.trim().toLowerCase(); + const exists = state.exerciseRef.find( + e => e.name.trim().toLowerCase() === normalizedName + ); + if (!exists) { + state.exerciseRef.push({ + id: genId(), + name: name.trim(), + workoutType, + }); + } else if (!exists.workoutType && workoutType) { + exists.workoutType = workoutType; + } +} + +export function toggleExercise(exerciseId) { + state.expandedExerciseId = + state.expandedExerciseId === exerciseId ? null : exerciseId; + renderMainScreen(); +} + +export function deleteExercise(workoutId, exerciseId) { + if (!confirm('Удалить упражнение?')) return; + const w = state.workouts.find(x => x.id === workoutId); + w.exercises = w.exercises.filter(e => e.id !== exerciseId); + saveState(); + renderMainScreen(); +} + +export function getExerciseHistory(exerciseName) { + const normalizedName = exerciseName.trim().toLowerCase(); + const history = []; + for (const w of 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); +} + +window.EL = { addExercise, toggleExercise, deleteExercise }; diff --git a/frontend/js/logic/metrics.js b/frontend/js/logic/metrics.js new file mode 100644 index 0000000..aed6ca8 --- /dev/null +++ b/frontend/js/logic/metrics.js @@ -0,0 +1,47 @@ +import { state, saveState, genId } from '../state.js'; +import { renderMainScreen } from '../render/main-screen.js'; +import { showBodyMetricsDialog } from '../modals/metrics-modal.js'; + +export function showBodyMetrics() { + showBodyMetricsDialog(state.bodyMetrics, (newMetric) => { + if (!newMetric) return; + newMetric.id = genId(); + state.bodyMetrics.unshift(newMetric); + state.bodyMetrics.sort((a, b) => new Date(b.date) - new Date(a.date)); + saveState(); + renderMainScreen(); + alert('Замеры сохранены!'); + }); +} + +export function editBodyMetric(metricId) { + const metric = state.bodyMetrics.find(m => m.id === metricId); + if (!metric) return; + showBodyMetricsDialog(state.bodyMetrics, (updatedData) => { + if (!updatedData) return; + Object.assign(metric, updatedData); + state.bodyMetrics.sort((a, b) => new Date(b.date) - new Date(a.date)); + saveState(); + // Если сейчас на экране настроек — перерисуем список + const list = document.getElementById('body-metrics-list'); + if (list && typeof window.SettingsLogic !== 'undefined') { + window.SettingsLogic._renderBodyMetricsList(); + } else { + renderMainScreen(); + } + }, metric); +} + +export function deleteMetric(id) { + if (!confirm('Удалить замер?')) return; + state.bodyMetrics = state.bodyMetrics.filter(m => m.id !== id); + saveState(); + const list = document.getElementById('body-metrics-list'); + if (list && typeof window.SettingsLogic !== 'undefined') { + window.SettingsLogic._renderBodyMetricsList(); + } else { + renderMainScreen(); + } +} + +window.ML = { showBodyMetrics, editBodyMetric, deleteMetric }; diff --git a/frontend/js/logic/set.js b/frontend/js/logic/set.js new file mode 100644 index 0000000..c40943e --- /dev/null +++ b/frontend/js/logic/set.js @@ -0,0 +1,45 @@ +import { state, saveState, genId } from '../state.js'; +import { renderMainScreen } from '../render/main-screen.js'; +import { showSetDialog } from '../modals/set-modal.js'; +import { getExerciseHistory } from './exercise.js'; + +export function addSet(workoutId, exerciseId) { + const w = state.workouts.find(x => x.id === workoutId); + const ex = w.exercises.find(e => e.id === exerciseId); + const lastSet = ex.sets[ex.sets.length - 1]; + const history = getExerciseHistory(ex.name); + + showSetDialog(lastSet, history, (setData) => { + if (!setData) return; + setData.id = genId(); + setData.timestamp = new Date().toISOString(); + ex.sets.push(setData); + saveState(); + renderMainScreen(); + }); +} + +export function editSet(workoutId, exerciseId, setId) { + const w = state.workouts.find(x => x.id === workoutId); + const ex = w.exercises.find(e => e.id === exerciseId); + const set = ex.sets.find(s => s.id === setId); + const history = getExerciseHistory(ex.name); + + showSetDialog(set, history, (setData) => { + if (!setData) return; + Object.assign(set, setData); + saveState(); + renderMainScreen(); + }); +} + +export function deleteSet(workoutId, exerciseId, setId) { + if (!confirm('Удалить подход?')) return; + const w = state.workouts.find(x => x.id === workoutId); + const ex = w.exercises.find(e => e.id === exerciseId); + ex.sets = ex.sets.filter(s => s.id !== setId); + saveState(); + renderMainScreen(); +} + +window.SL = { addSet, editSet, deleteSet }; diff --git a/frontend/js/logic/settings.js b/frontend/js/logic/settings.js new file mode 100644 index 0000000..4c1a2aa --- /dev/null +++ b/frontend/js/logic/settings.js @@ -0,0 +1,185 @@ +import { state, saveState } from '../state.js'; +import { showEditExerciseDialog } from '../modals/edit-exercise-modal.js'; + +export function showSettings() { + const app = document.getElementById('app'); + app.innerHTML = ''; + + const screen = document.createElement('div'); + screen.className = 'settings-screen'; + screen.innerHTML = ` +
+

⚙️ Настройки

+ +
+
+

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

+

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

+
+
+ + + +
+
+
+
+
+

📏 Замеры тела

+
+
+
+

🗑 Данные

+ +
+ `; + app.appendChild(screen); + _renderRefExercises(); + _renderBodyMetricsList(); +} + +export function addRefExercise() { + const input = document.getElementById('new-ref-exercise'); + const typeSelect = document.getElementById('new-ref-type'); + const name = input.value.trim(); + if (!name) return; + const normalizedName = name.trim().toLowerCase(); + const exists = state.exerciseRef.find( + e => e.name.trim().toLowerCase() === normalizedName + ); + if (!exists) { + state.exerciseRef.push({ + id: (state.nextId++).toString(), + name: name.trim(), + workoutType: typeSelect.value, + }); + } + saveState(); + input.value = ''; + typeSelect.value = ''; + _renderRefExercises(); +} + +export function deleteRefExercise(id) { + if (!confirm('Удалить из справочника? (в тренировках останется)')) return; + state.exerciseRef = state.exerciseRef.filter(e => e.id !== id); + saveState(); + _renderRefExercises(); +} + +export function updateRefExerciseType(id, newType) { + const ex = state.exerciseRef.find(e => e.id === id); + if (ex) { + ex.workoutType = newType; + saveState(); + } +} + +export function editRefExercise(id) { + const ex = state.exerciseRef.find(e => e.id === id); + if (!ex) return; + showEditExerciseDialog(ex, (updatedData) => { + if (!updatedData) return; + Object.assign(ex, updatedData); + saveState(); + _renderRefExercises(); + }); +} + +export function clearAllData() { + if (!confirm('Удалить ВСЕ данные? Это действие необратимо!')) return; + if (!confirm('Точно уверены?')) return; + localStorage.clear(); + location.reload(); +} + +export function _renderRefExercises() { + const list = document.getElementById('ref-exercises-list'); + if (!list) return; + if (state.exerciseRef.length === 0) { + list.innerHTML = '

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

'; + return; + } + const sorted = [...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; + 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(''); +} + +export function _renderBodyMetricsList() { + const list = document.getElementById('body-metrics-list'); + if (!list) return; + if (state.bodyMetrics.length === 0) { + list.innerHTML = '

Нет замеров

'; + return; + } + list.innerHTML = 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(''); +} + +window.SettingsLogic = { + show: showSettings, + addRefExercise, deleteRefExercise, updateRefExerciseType, + editRefExercise, clearAllData, + _renderRefExercises, _renderBodyMetricsList, +}; diff --git a/frontend/js/logic/workout.js b/frontend/js/logic/workout.js new file mode 100644 index 0000000..ab64723 --- /dev/null +++ b/frontend/js/logic/workout.js @@ -0,0 +1,66 @@ +import { state, saveState, genId } from '../state.js'; +import { renderMainScreen } from '../render/main-screen.js'; +import { showEditWorkoutDateDialog } from '../modals/edit-workout-date-modal.js'; + +export function startWorkout() { + const w = { + id: genId(), + startTime: new Date().toISOString(), + endTime: null, + type: '', + exercises: [], + cardio: [], + notes: '', + synced: false, + }; + state.workouts.unshift(w); + state.expandedWorkoutId = w.id; + saveState(); + renderMainScreen(); +} + +export function finishWorkout(workoutId) { + if (!confirm('Завершить тренировку?')) return; + const w = getWorkout(workoutId); + w.endTime = new Date().toISOString(); + saveState(); + renderMainScreen(); +} + +export function deleteWorkout(workoutId) { + if (!confirm('Удалить тренировку полностью?')) return; + state.workouts = state.workouts.filter(w => w.id !== workoutId); + saveState(); + renderMainScreen(); +} + +export function toggleWorkout(workoutId) { + state.expandedWorkoutId = + state.expandedWorkoutId === workoutId ? null : workoutId; + renderMainScreen(); +} + +export function getWorkout(id) { + return state.workouts.find(w => w.id === id); +} + +export function getActiveWorkout() { + return state.workouts.find(w => !w.endTime); +} + +export function editWorkoutDate(workoutId) { + const w = getWorkout(workoutId); + if (!w) return; + showEditWorkoutDateDialog(w, (newDates) => { + if (!newDates) return; + w.startTime = newDates.startTime; + w.endTime = newDates.endTime; + saveState(); + renderMainScreen(); + }); +} + +window.WL = { + startWorkout, finishWorkout, deleteWorkout, + toggleWorkout, editWorkoutDate, +}; diff --git a/frontend/js/modals/cardio-modal.js b/frontend/js/modals/cardio-modal.js new file mode 100644 index 0000000..6f15317 --- /dev/null +++ b/frontend/js/modals/cardio-modal.js @@ -0,0 +1,57 @@ +import { showModal, closeModal, stepperHtml } from './modal-base.js'; + +let _cb = null; + +export function showCardioDialog(existing, callback) { + _cb = callback; + const e = existing || {}; + showModal(``); +} + +export function submit() { + // 1. Сначала считываем все значения, пока модалка ещё в DOM + const result = { + type: document.getElementById('m-ct').value, + purpose: document.getElementById('m-cp').value, + duration: parseInt(document.getElementById('m-cd').value) || 0, + distance: parseFloat(document.getElementById('m-cdi').value) || 0, + speed: parseFloat(document.getElementById('m-cs').value) || 0, + incline: parseFloat(document.getElementById('m-ci').value) || 0, + resistance: parseInt(document.getElementById('m-cr').value) || 0, + notes: document.getElementById('m-cn').value.trim(), + }; + + // 2. Только теперь закрываем модалку + closeModal(); + + // 3. Отдаём результат + _cb?.(result); +} + +window.CardioModal = { submit }; diff --git a/frontend/js/modals/edit-exercise-modal.js b/frontend/js/modals/edit-exercise-modal.js new file mode 100644 index 0000000..69eae25 --- /dev/null +++ b/frontend/js/modals/edit-exercise-modal.js @@ -0,0 +1,42 @@ +import { showModal, closeModal } from './modal-base.js'; + +let _cb = null; + +export function showEditExerciseDialog(exercise, callback) { + _cb = callback; + showModal(` + + `); +} + +export function submit() { + const name = document.getElementById('m-edit-ex-name').value.trim(); + const type = document.getElementById('m-edit-ex-type').value; + if (!name) { alert('Название не может быть пустым'); return; } + closeModal(); + if (_cb) _cb({ name, workoutType: type }); +} + +window.EditExModal = { submit }; diff --git a/frontend/js/modals/edit-workout-date-modal.js b/frontend/js/modals/edit-workout-date-modal.js new file mode 100644 index 0000000..0f47fa6 --- /dev/null +++ b/frontend/js/modals/edit-workout-date-modal.js @@ -0,0 +1,41 @@ +import { showModal, closeModal } from './modal-base.js'; +import { toDatetimeLocal } from '../utils.js'; + +let _cb = null; + +export function showEditWorkoutDateDialog(workout, callback) { + _cb = callback; + const startValue = toDatetimeLocal(workout.startTime); + const endValue = workout.endTime ? toDatetimeLocal(workout.endTime) : ''; + + showModal(` + + `); +} + +export function submit() { + const startStr = document.getElementById('m-w-start').value; + const endStr = document.getElementById('m-w-end').value; + if (!startStr) { alert('Укажите дату начала'); return; } + closeModal(); + if (_cb) _cb({ + startTime: new Date(startStr).toISOString(), + endTime: endStr ? new Date(endStr).toISOString() : null, + }); +} + +window.EditDateModal = { submit }; diff --git a/frontend/js/modals/exercise-modal.js b/frontend/js/modals/exercise-modal.js new file mode 100644 index 0000000..29f5bf6 --- /dev/null +++ b/frontend/js/modals/exercise-modal.js @@ -0,0 +1,60 @@ +import { showModal, closeModal } from './modal-base.js'; +import { state } from '../state.js'; + +let _cb = null; +let _names = []; + +export function showExerciseDialog(workoutType, callback) { + _cb = callback; + const nameCount = {}; + state.exerciseRef.forEach(ex => { + const bonus = (workoutType && ex.workoutType === workoutType) ? 100 : 10; + nameCount[ex.name] = (nameCount[ex.name] || 0) + bonus; + }); + state.workouts.forEach(w => w.exercises.forEach(ex => { + nameCount[ex.name] = (nameCount[ex.name] || 0) + 1; + })); + _names = Object.keys(nameCount).sort((a, b) => { + const d = nameCount[b] - nameCount[a]; + return d !== 0 ? d : a.localeCompare(b); + }); + + showModal(``); + setTimeout(() => { document.getElementById('m-ex-name').focus(); filter(); }, 50); +} + +export function filter() { + const q = document.getElementById('m-ex-name').value.trim().toLowerCase(); + const list = document.getElementById('m-ex-autocomplete'); + let f = _names; + if (q) f = f.filter(n => n.toLowerCase().includes(q)); + if (!f.length) { list.innerHTML = '
Нет совпадений
'; return; } + list.innerHTML = f.slice(0, 15).map(name => { + const count = state.workouts.reduce((s, w) => s + w.exercises.filter(e => e.name === name).length, 0); + return `
${name}${count ? `${count}×` : ''}
`; + }).join(''); +} + +export function select(name) { + document.getElementById('m-ex-name').value = name; + document.getElementById('m-ex-autocomplete').innerHTML = ''; +} + +export function submit() { + const name = document.getElementById('m-ex-name').value.trim(); + closeModal(); + if (name && _cb) _cb(name); +} + +window.ExerciseModal = { filter, select, submit }; diff --git a/frontend/js/modals/metrics-modal.js b/frontend/js/modals/metrics-modal.js new file mode 100644 index 0000000..60f6cda --- /dev/null +++ b/frontend/js/modals/metrics-modal.js @@ -0,0 +1,44 @@ +import { showModal, closeModal, stepperHtml } from './modal-base.js'; +import { toDatetimeLocal } from '../utils.js'; + +let _cb = null; + +export function showBodyMetricsDialog(metrics, callback, editing = null) { + _cb = callback; + const src = editing || metrics[0] || {}; + const isEdit = !!editing; + const dateVal = src.date ? toDatetimeLocal(src.date) : toDatetimeLocal(new Date().toISOString()); + + showModal(``); +} + +export function submit(isEdit) { + const r = { + date: new Date(document.getElementById('m-md').value).toISOString(), + weight: parseFloat(document.getElementById('m-mw').value)||null, + chest: parseInt(document.getElementById('m-mc').value)||null, + waist: parseInt(document.getElementById('m-mwa').value)||null, + hips: parseInt(document.getElementById('m-mh').value)||null, + arms: parseFloat(document.getElementById('m-ma').value)||null, + legs: parseFloat(document.getElementById('m-ml').value)||null, + }; + if (!Object.values(r).some((v,i) => i>0 && v!==null)) { alert('Заполните поле'); return; } + closeModal(); _cb?.(r, isEdit); +} + +window.MetricsModal = { submit }; diff --git a/frontend/js/modals/modal-base.js b/frontend/js/modals/modal-base.js new file mode 100644 index 0000000..c5a07e3 --- /dev/null +++ b/frontend/js/modals/modal-base.js @@ -0,0 +1,33 @@ +const container = () => document.getElementById('modal-container'); + +export function showModal(html) { + container().innerHTML = ``; +} + +export function closeModal() { + container().innerHTML = ''; +} + +export function closeOnBg(e) { + if (e.target.classList.contains('modal-overlay')) closeModal(); +} + +export function stepperHtml(id, value, step, placeholder, inputmode = 'decimal') { + const v = value !== '' && value != null ? value : ''; + return `
+ + + +
`; +} + +export function stepValue(id, delta) { + const input = document.getElementById(id); + if (!input) return; + const current = parseFloat(input.value) || 0; + const step = parseFloat(input.step) || 1; + const newVal = Math.max(0, current + delta); + input.value = parseFloat((Math.round(newVal / step) * step).toFixed(4)); +} + +window.ModalBase = { closeOnBg, closeModal, stepValue }; diff --git a/frontend/js/modals/set-modal.js b/frontend/js/modals/set-modal.js new file mode 100644 index 0000000..81f9691 --- /dev/null +++ b/frontend/js/modals/set-modal.js @@ -0,0 +1,127 @@ +import { showModal, closeModal, stepperHtml } from './modal-base.js'; +import { formatShortDate, formatSet } from '../utils.js'; + +let _data = {}; +let _cb = null; + +export function showSetDialog(existingSet, history, callback) { + _cb = callback; + const isEdit = !!existingSet; + const isDropset = existingSet?.isDropset || false; + + let historyHtml = ''; + if (history?.length) { + const rows = history.map(h => `
+ ${formatShortDate(h.date)} + ${h.sets.map(formatSet).join(', ')} +
`).join(''); + historyHtml = `
📊 Прошлые тренировки
${rows}
`; + } + + showModal(``); + + _data = { + isDropset, rating: existingSet?.rating || 3, + subSets: existingSet?.subSets ? JSON.parse(JSON.stringify(existingSet.subSets)) : [], + }; + if (isDropset) { if (!_data.subSets.length) addSubset(); else renderSubsets(); } + rating(_data.rating); +} + +export function toggleDropset() { + _data.isDropset = document.getElementById('m-set-dropset').checked; + document.getElementById('m-set-normal').classList.toggle('hidden', _data.isDropset); + document.getElementById('m-set-dropset-block').classList.toggle('hidden', !_data.isDropset); + if (_data.isDropset && !_data.subSets.length) addSubset(); + else if (_data.isDropset) renderSubsets(); +} + +export function addSubset() { + const last = _data.subSets[_data.subSets.length - 1]; + _data.subSets.push({ weight: last?.weight || '', reps: last?.reps || '' }); + renderSubsets(); +} + +export function renderSubsets() { + document.getElementById('m-subsets-list').innerHTML = _data.subSets.map((s, i) => ` +
+
+ + + +
+
+ + + +
+ +
+ `).join(''); +} + +export function updateSubset(i, field, value) { + _data.subSets[i][field] = value; +} + +export function stepSubset(i, field, delta) { + const current = parseFloat(_data.subSets[i][field]) || 0; + const step = field === 'weight' ? 0.5 : 1; + const newVal = Math.max(0, current + delta); + _data.subSets[i][field] = parseFloat((Math.round(newVal / step) * step).toFixed(4)); + renderSubsets(); // Перерисовываем, чтобы обновить значение в input +} + +export function removeSubset(i) { + _data.subSets.splice(i, 1); + renderSubsets(); +} + +export function rating(r) { + _data.rating = r; + document.querySelectorAll('#m-rating-grid .rating-btn').forEach(b => b.classList.toggle('active', +b.dataset.rating === r)); +} + +export function submit() { + if (_data.isDropset) { + _data.subSets.forEach((s, i) => { + const w = document.getElementById(`m-sw-${i}`); + const r = document.getElementById(`m-sr-${i}`); + if (w) s.weight = w.value; if (r) s.reps = r.value; + }); + if (!_data.subSets.length) { alert('Добавьте сбрасывание'); return; } + closeModal(); _cb?.({ isDropset: true, subSets: _data.subSets.map(s => ({ weight: parseFloat(s.weight)||0, reps: parseInt(s.reps)||0 })), rating: _data.rating }); + } else { + const w = parseFloat(document.getElementById('m-set-weight').value); + const r = parseInt(document.getElementById('m-set-reps').value); + if (!w && !r) { alert('Укажите вес и повторения'); return; } + closeModal(); _cb?.({ isDropset: false, weight: w||0, reps: r||0, rating: _data.rating }); + } +} + +// Обновляем экспорт в window +window.SetModal = { + toggleDropset, addSubset, removeSubset, rating, submit, + updateSubset, stepSubset +}; diff --git a/frontend/js/render/cardio-card.js b/frontend/js/render/cardio-card.js new file mode 100644 index 0000000..3294e49 --- /dev/null +++ b/frontend/js/render/cardio-card.js @@ -0,0 +1,30 @@ +export function 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; +} diff --git a/frontend/js/render/exercise-card.js b/frontend/js/render/exercise-card.js new file mode 100644 index 0000000..6d42f2a --- /dev/null +++ b/frontend/js/render/exercise-card.js @@ -0,0 +1,60 @@ +import { state } from '../state.js'; +import { renderSetRow } from './set-row.js'; + +export function renderExercise(w, ex, isReadOnly = false) { + const isExpanded = 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(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 = () => window.SL.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(); + window.EL.deleteExercise(w.id, ex.id); + }; + body.appendChild(delBtn); + } + } + + card.appendChild(body); + return card; +} diff --git a/frontend/js/render/main-screen.js b/frontend/js/render/main-screen.js new file mode 100644 index 0000000..c7d8df7 --- /dev/null +++ b/frontend/js/render/main-screen.js @@ -0,0 +1,43 @@ +// Рендеринг главного экрана: список тренировок. +// Получает данные из state, возвращает DOM. + +import { state } from '../state.js'; +import { renderWorkoutCard } from './workout-card.js'; +import { startWorkout } from '../logic/workout.js'; + +export function renderMainScreen() { + const app = document.getElementById('app'); + app.innerHTML = ''; + + if (state.workouts.length === 0) { + app.innerHTML = ` +
+

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

+

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

+ +
+ `; + document.getElementById('btn-start-first').onclick = startWorkout; + return; + } + + // Сортировка: активные сверху, завершённые снизу + const sorted = [...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); + }); + + const hasActive = sorted.some(w => !w.endTime); + if (!hasActive) { + const btn = document.createElement('button'); + btn.className = 'btn mb-10'; + btn.textContent = '▶ Начать новую тренировку'; + btn.onclick = startWorkout; + app.appendChild(btn); + } + + sorted.forEach(w => { + app.appendChild(renderWorkoutCard(w)); + }); +} diff --git a/frontend/js/render/set-row.js b/frontend/js/render/set-row.js new file mode 100644 index 0000000..818eca2 --- /dev/null +++ b/frontend/js/render/set-row.js @@ -0,0 +1,35 @@ +export function 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; +} diff --git a/frontend/js/render/workout-card.js b/frontend/js/render/workout-card.js new file mode 100644 index 0000000..57b56d9 --- /dev/null +++ b/frontend/js/render/workout-card.js @@ -0,0 +1,169 @@ +import { state } from '../state.js'; +import { renderExercise } from './exercise-card.js'; +import { renderCardioItem } from './cardio-card.js'; +import { formatDate } from '../utils.js'; + +export function renderWorkoutCard(w) { + const isActive = !w.endTime; + const isExpanded = 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'}`; + const dateStr = formatDate(w.startTime); + 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(renderWorkoutDetails(w)); + } + div.appendChild(body); + return div; +} + +export function renderWorkoutDetails(w) { + const wrap = document.createElement('div'); + const isActive = !w.endTime; + const isReadOnly = w.synced && w.endTime; + + 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); + + // Кнопки добавления + if (isActive && !isReadOnly) { + const addSection = document.createElement('div'); + addSection.className = 'add-section'; + addSection.innerHTML = ` + + + `; + wrap.appendChild(addSection); + } + + // Кардио: разминка сверху + 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(renderCardioItem(w.id, c, isReadOnly))); + } + + // Упражнения + [...w.exercises].reverse().forEach(ex => { + wrap.appendChild(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(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; + window.App.saveAndRender(); + }; + } + 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; +} diff --git a/frontend/js/state.js b/frontend/js/state.js new file mode 100644 index 0000000..cef23bb --- /dev/null +++ b/frontend/js/state.js @@ -0,0 +1,46 @@ +const STORAGE_KEY = 'gym_tracker_v5'; + +export const state = { + workouts: [], + exerciseRef: [], + bodyMetrics: [], + expandedWorkoutId: null, + expandedExerciseId: null, + nextId: 1, +}; + +export function loadState() { + const saved = localStorage.getItem(STORAGE_KEY); + if (saved) { + try { + const parsed = JSON.parse(saved); + Object.assign(state, parsed); + if (!state.exerciseRef) state.exerciseRef = []; + if (!state.bodyMetrics) state.bodyMetrics = []; + } catch (e) { + console.error('Failed to load state', e); + } + } +} + +export function saveState() { + localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); + updateSyncStatus(); +} + +export function genId() { + return (state.nextId++).toString(); +} + +export function updateSyncStatus() { + const dirty = state.workouts.filter(w => !w.synced); + const el = document.getElementById('sync-status'); + if (!el) return; + if (dirty.length === 0) { + el.textContent = '● Всё синхронизировано'; + el.className = 'sync-status synced'; + } else { + el.textContent = `● ${dirty.length} не сохран.`; + el.className = 'sync-status pending'; + } +} diff --git a/frontend/js/sync.js b/frontend/js/sync.js new file mode 100644 index 0000000..f0e2799 --- /dev/null +++ b/frontend/js/sync.js @@ -0,0 +1,18 @@ +import { state, saveState } from './state.js'; + +export function getDirtyWorkouts() { + return state.workouts.filter(w => !w.synced); +} + +export async function sync() { + const dirty = getDirtyWorkouts(); + if (dirty.length === 0) { + alert('Нет данных для синхронизации'); + return; + } + // TODO: заменить на fetch('/api/sync', ...) + console.log('Syncing:', JSON.stringify(dirty, null, 2)); + dirty.forEach(w => w.synced = true); + saveState(); + alert(`Отправлено тренировок: ${dirty.length}`); +} diff --git a/frontend/js/utils.js b/frontend/js/utils.js new file mode 100644 index 0000000..de1c4f5 --- /dev/null +++ b/frontend/js/utils.js @@ -0,0 +1,28 @@ +export function formatDate(isoString) { + const d = new Date(isoString); + return d.toLocaleDateString('ru-RU') + ' ' + + d.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' }); +} + +export function formatShortDate(isoString) { + return new Date(isoString).toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit' }); +} + +export function formatSet(set) { + if (set.isDropset && set.subSets) { + return set.subSets.map(s => `${s.weight}×${s.reps}`).join('-'); + } + return `${set.weight}×${set.reps}`; +} + +export function escapeHtml(str) { + const div = document.createElement('div'); + div.textContent = str; + return div.innerHTML; +} + +export function toDatetimeLocal(isoString) { + const d = new Date(isoString); + const pad = n => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; +} diff --git a/frontend/styles/components.css b/frontend/styles/components.css new file mode 100644 index 0000000..132cdf6 --- /dev/null +++ b/frontend/styles/components.css @@ -0,0 +1,93 @@ +/* === 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; } + +/* === Stepper === */ +.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; } + +.stepper-row { + display: flex; gap: 6px; margin-bottom: 8px; align-items: center; flex-wrap: nowrap; +} +.stepper-row .stepper-field { flex: 1; min-width: 0; } + +/* === Rating === */ +.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); } + +/* === Icon buttons === */ +.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); } + +/* === Empty state === */ +.empty-state { text-align: center; padding: 40px 20px; color: var(--text-secondary); } +.empty-state h3 { color: var(--text); margin-bottom: 10px; } + +/* === Read-only === */ +.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; +} diff --git a/frontend/styles/main.css b/frontend/styles/main.css new file mode 100644 index 0000000..a2642d6 --- /dev/null +++ b/frontend/styles/main.css @@ -0,0 +1,57 @@ +: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; } + +.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; } diff --git a/frontend/styles/modal.css b/frontend/styles/modal.css new file mode 100644 index 0000000..83a1df8 --- /dev/null +++ b/frontend/styles/modal.css @@ -0,0 +1,25 @@ +/* === 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; } + +/* === 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); } diff --git a/frontend/styles/settings.css b/frontend/styles/settings.css new file mode 100644 index 0000000..d109b86 --- /dev/null +++ b/frontend/styles/settings.css @@ -0,0 +1,18 @@ +/* === Settings === */ +.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; } diff --git a/frontend/styles/workout.css b/frontend/styles/workout.css new file mode 100644 index 0000000..cf23c0e --- /dev/null +++ b/frontend/styles/workout.css @@ -0,0 +1,70 @@ +/* === Workout accordion === */ +.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; } + +/* === Sets === */ +.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; } + +/* === Dropset === */ +.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; +} + +/* === 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; } + +/* === Finish 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 section === */ +.add-section { display: flex; gap: 8px; margin-bottom: 10px; } +.add-section .btn { flex: 1; } + +/* === 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; } diff --git a/index.html b/index.html deleted file mode 100644 index 3a2b0ad..0000000 --- a/index.html +++ /dev/null @@ -1,30 +0,0 @@ - - - - - - Gym Tracker - - - - -
-
-

Gym Tracker

-
● Локально
-
-
- - - -
-
- -
- - - - - - - diff --git a/modal.js b/modal.js deleted file mode 100644 index 6ae068a..0000000 --- a/modal.js +++ /dev/null @@ -1,602 +0,0 @@ -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/schema.sql b/sql/schema.sql similarity index 100% rename from schema.sql rename to sql/schema.sql diff --git a/seed.sql b/sql/seed.sql similarity index 100% rename from seed.sql rename to sql/seed.sql diff --git a/verify.sql b/sql/verify.sql similarity index 100% rename from verify.sql rename to sql/verify.sql diff --git a/styles.css b/styles.css deleted file mode 100644 index 7d38a57..0000000 --- a/styles.css +++ /dev/null @@ -1,571 +0,0 @@ -: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; }