865 lines
35 KiB
JavaScript
865 lines
35 KiB
JavaScript
// ============================================================
|
||
// 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 = `
|
||
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:20px;">
|
||
<h2>⚙️ Настройки</h2>
|
||
<button class="btn btn-small btn-secondary" onclick="App.render()">← Назад</button>
|
||
</div>
|
||
|
||
<div class="settings-section">
|
||
<h3>📋 Справочник упражнений</h3>
|
||
<p class="text-muted text-small mb-10">
|
||
Управляйте списком упражнений и привязывайте их к типам тренировок.
|
||
</p>
|
||
<div class="field-group">
|
||
<div style="display:flex; gap:6px;">
|
||
<input type="text" id="new-ref-exercise" placeholder="Новое упражнение...">
|
||
<select id="new-ref-type" style="width: auto; min-width: 120px;">
|
||
<option value="">тип...</option>
|
||
<option value="грудь-руки">грудь-руки</option>
|
||
<option value="ноги">ноги</option>
|
||
<option value="спина-плечи">спина-плечи</option>
|
||
<option value="круговая">круговая</option>
|
||
<option value="силовая">силовая</option>
|
||
<option value="многоповторная">многоповторная</option>
|
||
</select>
|
||
<button class="btn btn-small" style="width:auto; padding: 0 20px;"
|
||
onclick="App.addRefExercise()">+</button>
|
||
</div>
|
||
</div>
|
||
<div id="ref-exercises-list"></div>
|
||
</div>
|
||
|
||
<div class="settings-section">
|
||
<h3>📏 Замеры тела</h3>
|
||
<div id="body-metrics-list"></div>
|
||
</div>
|
||
|
||
<div class="settings-section">
|
||
<h3>🗑 Данные</h3>
|
||
<button class="btn btn-danger" onclick="App.clearAllData()">Очистить все данные</button>
|
||
</div>
|
||
`;
|
||
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 = '<p class="text-muted text-small">Справочник пуст</p>';
|
||
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 `
|
||
<div class="exercise-ref-item">
|
||
<div style="flex:1;">
|
||
<span class="name">${ex.name}</span>
|
||
<span class="count">(${count}×)</span>
|
||
${ex.workoutType ? `<span class="type-badge">${ex.workoutType}</span>` : ''}
|
||
</div>
|
||
<div style="display:flex; gap:4px; align-items:center;">
|
||
<select onchange="App.updateRefExerciseType('${ex.id}', this.value)"
|
||
style="width:auto; min-width:100px; padding:6px; font-size:0.8rem;">
|
||
<option value="">тип...</option>
|
||
<option value="грудь-руки" ${ex.workoutType==='грудь-руки'?'selected':''}>грудь-руки</option>
|
||
<option value="ноги" ${ex.workoutType==='ноги'?'selected':''}>ноги</option>
|
||
<option value="спина-плечи" ${ex.workoutType==='спина-плечи'?'selected':''}>спина-плечи</option>
|
||
<option value="круговая" ${ex.workoutType==='круговая'?'selected':''}>круговая</option>
|
||
<option value="силовая" ${ex.workoutType==='силовая'?'selected':''}>силовая</option>
|
||
<option value="многоповторная" ${ex.workoutType==='многоповторная'?'selected':''}>многоповторная</option>
|
||
</select>
|
||
<button class="icon-btn" onclick="App.editRefExercise('${ex.id}')">✎</button>
|
||
<button class="icon-btn danger" onclick="App.deleteRefExercise('${ex.id}')">✕</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
},
|
||
|
||
_renderBodyMetricsList() {
|
||
const list = document.getElementById('body-metrics-list');
|
||
if (!list) return;
|
||
|
||
if (this.state.bodyMetrics.length === 0) {
|
||
list.innerHTML = '<p class="text-muted text-small">Нет замеров</p>';
|
||
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 `
|
||
<div class="exercise-ref-item">
|
||
<div>
|
||
<div style="font-weight:500;">${dateStr}</div>
|
||
<div class="text-small text-muted">${parts.join(' • ') || '—'}</div>
|
||
</div>
|
||
<div style="display:flex; gap:4px;">
|
||
<button class="icon-btn" onclick="App.editBodyMetric('${m.id}')">✎</button>
|
||
<button class="icon-btn danger" onclick="App.deleteMetric('${m.id}')">✕</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).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 = `
|
||
<div class="empty-state">
|
||
<h3>🏋️ Нет тренировок</h3>
|
||
<p>Начните первую тренировку</p>
|
||
<button class="btn" onclick="App.startWorkout()">▶ Начать тренировку</button>
|
||
</div>
|
||
`;
|
||
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 = '<span class="workout-badge synced-badge">✓ архив</span>';
|
||
} else if (w.synced) {
|
||
statusBadge = '<span class="workout-badge synced-badge">✓</span>';
|
||
} else {
|
||
statusBadge = '<span class="workout-badge dirty">● не сохранено</span>';
|
||
}
|
||
|
||
const editDateBtn = !isReadOnly
|
||
? `<span class="editable-date" onclick="event.stopPropagation(); App.editWorkoutDate('${w.id}')">
|
||
<span class="edit-icon">✎</span>
|
||
</span>`
|
||
: '';
|
||
|
||
div.innerHTML = `
|
||
<div class="${headerClass}" onclick="App.toggleWorkout('${w.id}')">
|
||
<div>
|
||
<div style="font-weight:600;">${isActive ? '🔥 Тренировка' : '✅ Завершена'}</div>
|
||
<div class="workout-meta">
|
||
${dateStr} ${editDateBtn} • ${typeStr}
|
||
</div>
|
||
<div class="workout-meta">Упр: ${exCount} • Кардио: ${cardioCount}</div>
|
||
</div>
|
||
<div>${statusBadge} <span class="chevron ${isExpanded ? 'open' : ''}">›</span></div>
|
||
</div>
|
||
`;
|
||
|
||
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 = `
|
||
<div class="field-group" style="margin:0;">
|
||
<span class="text-muted text-small">Тип тренировки</span>
|
||
<div style="font-weight:600; margin-top:4px;">${w.type || '—'}</div>
|
||
</div>
|
||
`;
|
||
} else {
|
||
typeCard.innerHTML = `
|
||
<label class="field-group" style="margin:0;">
|
||
<span>Тип тренировки</span>
|
||
<select onchange="App.getWorkout('${w.id}').type = this.value; App.save();" ${isReadOnly ? 'disabled' : ''}>
|
||
<option value="">— выберите —</option>
|
||
<option ${w.type==='грудь-руки'?'selected':''}>грудь-руки</option>
|
||
<option ${w.type==='ноги'?'selected':''}>ноги</option>
|
||
<option ${w.type==='спина-плечи'?'selected':''}>спина-плечи</option>
|
||
<option ${w.type==='круговая'?'selected':''}>круговая</option>
|
||
<option ${w.type==='силовая'?'selected':''}>силовая</option>
|
||
<option ${w.type==='многоповторная'?'selected':''}>многоповторная</option>
|
||
</select>
|
||
</label>
|
||
`;
|
||
}
|
||
wrap.appendChild(typeCard);
|
||
|
||
// Кнопки добавления (только для активной и не read-only)
|
||
if (isActive && !isReadOnly) {
|
||
const addSection = document.createElement('div');
|
||
addSection.className = 'add-section';
|
||
addSection.innerHTML = `
|
||
<button class="btn btn-secondary" onclick="App.addExercise('${w.id}')">+ Упражнение</button>
|
||
<button class="btn btn-secondary" onclick="App.addCardio('${w.id}')">+ Кардио</button>
|
||
`;
|
||
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 = `
|
||
<div class="field-group" style="margin:0;">
|
||
<span class="text-muted text-small">Комментарий</span>
|
||
<div style="margin-top:4px; white-space:pre-wrap;">${w.notes || '—'}</div>
|
||
</div>
|
||
`;
|
||
} else {
|
||
notesCard.innerHTML = `
|
||
<label class="field-group" style="margin:0;">
|
||
<span>Комментарий к тренировке</span>
|
||
<textarea placeholder="Как прошла тренировка...">${w.notes || ''}</textarea>
|
||
</label>
|
||
`;
|
||
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 = `
|
||
<h3>🔒 Архив</h3>
|
||
<p class="text-muted text-small">Тренировка синхронизирована и завершена. Редактирование недоступно.</p>
|
||
`;
|
||
} else if (isActive) {
|
||
finishBlock.innerHTML = `
|
||
<h3>Завершение тренировки</h3>
|
||
<p class="text-muted text-small">После завершения данные останутся локально до синхронизации</p>
|
||
<button class="btn btn-danger" onclick="App.finishWorkout('${w.id}')">⏹ Закончить тренировку</button>
|
||
`;
|
||
} else {
|
||
finishBlock.innerHTML = `
|
||
<h3>Тренировка завершена</h3>
|
||
<p class="text-muted text-small">До синхронизации можно редактировать</p>
|
||
<button class="btn btn-danger" onclick="App.deleteWorkout('${w.id}')">🗑 Удалить</button>
|
||
`;
|
||
}
|
||
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 = `
|
||
<div class="exercise-header ${isExpanded ? 'expanded' : ''}" onclick="App.toggleExercise('${ex.id}')">
|
||
<div>
|
||
<span class="exercise-name">${ex.name}</span>
|
||
<span class="exercise-summary">${summary}</span>
|
||
</div>
|
||
<span class="chevron ${isExpanded ? 'open' : ''}">›</span>
|
||
</div>
|
||
`;
|
||
|
||
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 += `<p class="text-muted text-small">Пока нет подходов</p>`;
|
||
}
|
||
|
||
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 ? '' : `
|
||
<div class="set-actions">
|
||
<button class="icon-btn" onclick="App.editSet('${workoutId}', '${exerciseId}', '${set.id}')">✎</button>
|
||
<button class="icon-btn danger" onclick="App.deleteSet('${workoutId}', '${exerciseId}', '${set.id}')">✕</button>
|
||
</div>
|
||
`;
|
||
|
||
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 = `
|
||
<div class="set-row" style="grid-template-columns: 30px 1fr auto ${isReadOnly ? '0' : 'auto'};">
|
||
<div class="set-number">${index + 1}</div>
|
||
<div class="set-cell dropset">🔥 ${subsText}</div>
|
||
<div class="set-rating">${ratingStars}</div>
|
||
${actionsHtml}
|
||
</div>
|
||
`;
|
||
} else {
|
||
row.innerHTML = `
|
||
<div class="set-row">
|
||
<div class="set-number">${index + 1}</div>
|
||
<div class="set-cell">${set.weight || '—'} кг</div>
|
||
<div class="set-cell">${set.reps || '—'}</div>
|
||
<div class="set-rating">${'★'.repeat(set.rating || 3)}</div>
|
||
${actionsHtml}
|
||
</div>
|
||
`;
|
||
}
|
||
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 ? '' : `
|
||
<div class="set-actions">
|
||
<button class="icon-btn" onclick="App.editCardio('${workoutId}', '${c.id}')">✎</button>
|
||
<button class="icon-btn danger" onclick="App.deleteCardio('${workoutId}', '${c.id}')">✕</button>
|
||
</div>
|
||
`;
|
||
|
||
card.innerHTML = `
|
||
<div style="display:flex; justify-content:space-between; align-items:flex-start;">
|
||
<div>
|
||
<div style="font-weight:600;">${c.type || '—'} ${purposeLabels[c.purpose] || ''}</div>
|
||
<div class="text-small text-muted">${metrics.join(' • ') || '—'}</div>
|
||
${c.notes ? `<div class="text-small" style="margin-top:4px;">"${c.notes}"</div>` : ''}
|
||
</div>
|
||
${actionsHtml}
|
||
</div>
|
||
`;
|
||
return card;
|
||
},
|
||
};
|
||
|
||
App.init();
|