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 = `
-
- `;
-
- 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(`
+
${existing ? 'Редактировать' : 'Новое'} кардио
+
+
+
+
+
${stepperHtml('m-cd', e.duration??'', 1, 'мин', 'numeric')}
+
${stepperHtml('m-cdi', e.distance??'', 0.1, 'км')}
+
${stepperHtml('m-cs', e.speed??'', 0.5, 'км/ч')}
+
+
${stepperHtml('m-ci', e.incline??'', 0.5, '—')}
+
${stepperHtml('m-cr', e.resistance??'', 1, '—', 'numeric')}
+
+
+
+
+
`);
+}
+
+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(`
+
📏 Замеры ${isEdit ? '(ред.)' : ''}
+
+
+
${stepperHtml('m-mw', src.weight??'', 0.1, 'кг')}
+
${stepperHtml('m-mc', src.chest??'', 1, 'см', 'numeric')}
+
+
${stepperHtml('m-mwa', src.waist??'', 1, 'см', 'numeric')}
+
${stepperHtml('m-mh', src.hips??'', 1, 'см', 'numeric')}
+
+
${stepperHtml('m-ma', src.arms??'', 0.5, 'см')}
+
${stepperHtml('m-ml', src.legs??'', 0.5, 'см')}
+
+
+
+
`);
+}
+
+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 = `${html}
`;
+}
+
+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(`
+
${isEdit ? 'Редактировать' : 'Новый'} подход
+ ${historyHtml}
+
+
+
+
${stepperHtml('m-set-weight', existingSet?.weight ?? '', 0.5, 'Вес')}
+
${stepperHtml('m-set-reps', existingSet?.reps ?? '', 1, 'Повт.', 'numeric')}
+
+
+
+
+ ${[1,2,3,4,5].map(r => ``).join('')}
+
+
+
+
+
`);
+
+ _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 = `
+
+ `;
+
+ 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 = `${html}
`;
- },
-
- 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(`
-
-
${isEdit ? 'Редактировать' : 'Новый'} подход
-
- ${historyHtml}
-
-
-
-
-
-
-
-
-
- ${this.stepperHtml('m-set-weight', existingSet?.weight ?? '', 0.5, 'Вес', 'decimal')}
-
-
-
- ${this.stepperHtml('m-set-reps', existingSet?.reps ?? '', 1, 'Повт.', 'numeric')}
-
-
-
-
-
-
-
-
-
-
-
-
- ${[1,2,3,4,5].map(r => `
-
- `).join('')}
-
-
-
-
-
-
-
-
- `);
-
- 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(`
-
-
${isEdit ? 'Редактировать' : 'Новое'} кардио
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- ${this.stepperHtml('m-cardio-duration', existing?.duration ?? '', 1, 'мин', 'numeric')}
-
-
-
- ${this.stepperHtml('m-cardio-distance', existing?.distance ?? '', 0.1, 'км', 'decimal')}
-
-
-
- ${this.stepperHtml('m-cardio-speed', existing?.speed ?? '', 0.5, 'км/ч', 'decimal')}
-
-
-
-
-
-
- ${this.stepperHtml('m-cardio-incline', existing?.incline ?? '', 0.5, '—', 'decimal')}
-
-
-
- ${this.stepperHtml('m-cardio-resistance', existing?.resistance ?? '', 1, '—', 'numeric')}
-
-
-
-
-
-
-
-
-
-
-
-
-
- `);
- 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(`
-
-
📏 Замеры тела ${isEdit ? '(редактирование)' : ''}
-
-
-
-
-
-
-
-
-
- ${this.stepperHtml('m-metric-weight', source?.weight ?? '', 0.1, 'кг', 'decimal')}
-
-
-
- ${this.stepperHtml('m-metric-chest', source?.chest ?? '', 1, 'см', 'numeric')}
-
-
-
-
-
-
- ${this.stepperHtml('m-metric-waist', source?.waist ?? '', 1, 'см', 'numeric')}
-
-
-
- ${this.stepperHtml('m-metric-hips', source?.hips ?? '', 1, 'см', 'numeric')}
-
-
-
-
-
-
- ${this.stepperHtml('m-metric-arms', source?.arms ?? '', 0.5, 'см', 'decimal')}
-
-
-
- ${this.stepperHtml('m-metric-legs', source?.legs ?? '', 0.5, 'см', 'decimal')}
-
-
-
-
-
-
-
-
- `);
- 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; }