massive frontend refactoring (works good)
This commit is contained in:
+2
-2
@@ -1,2 +1,2 @@
|
|||||||
**/__pycache__
|
__pycache__/
|
||||||
**/*.pyc
|
*.pyc
|
||||||
|
|||||||
@@ -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 = `
|
|
||||||
<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();
|
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||||
|
<title>Gym Tracker</title>
|
||||||
|
<link rel="stylesheet" href="styles/main.css">
|
||||||
|
<link rel="stylesheet" href="styles/components.css">
|
||||||
|
<link rel="stylesheet" href="styles/workout.css">
|
||||||
|
<link rel="stylesheet" href="styles/modal.css">
|
||||||
|
<link rel="stylesheet" href="styles/settings.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header id="app-header"></header>
|
||||||
|
<main id="app" class="container"></main>
|
||||||
|
<div id="modal-container"></div>
|
||||||
|
<script type="module" src="js/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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 = `
|
||||||
|
<div>
|
||||||
|
<h2>Gym Tracker</h2>
|
||||||
|
<div id="sync-status" class="sync-status">● Локально</div>
|
||||||
|
</div>
|
||||||
|
<div class="header-actions">
|
||||||
|
<button class="icon-btn header-btn" onclick="ML.showBodyMetrics()" title="Замеры">📏</button>
|
||||||
|
<button class="icon-btn header-btn" onclick="SettingsLogic.show()" title="Настройки">⚙️</button>
|
||||||
|
<button class="btn btn-small btn-secondary" onclick="AppSync.sync()" title="Синхронизация">🔄</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 = `
|
||||||
|
<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.backToMain()">← Назад</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="SettingsLogic.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="SettingsLogic.clearAllData()">Очистить все данные</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
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 = '<p class="text-muted text-small">Справочник пуст</p>';
|
||||||
|
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 `
|
||||||
|
<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="SettingsLogic.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="SettingsLogic.editRefExercise('${ex.id}')">✎</button>
|
||||||
|
<button class="icon-btn danger" onclick="SettingsLogic.deleteRefExercise('${ex.id}')">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function _renderBodyMetricsList() {
|
||||||
|
const list = document.getElementById('body-metrics-list');
|
||||||
|
if (!list) return;
|
||||||
|
if (state.bodyMetrics.length === 0) {
|
||||||
|
list.innerHTML = '<p class="text-muted text-small">Нет замеров</p>';
|
||||||
|
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 `
|
||||||
|
<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="ML.editBodyMetric('${m.id}')">✎</button>
|
||||||
|
<button class="icon-btn danger" onclick="ML.deleteMetric('${m.id}')">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
window.SettingsLogic = {
|
||||||
|
show: showSettings,
|
||||||
|
addRefExercise, deleteRefExercise, updateRefExerciseType,
|
||||||
|
editRefExercise, clearAllData,
|
||||||
|
_renderRefExercises, _renderBodyMetricsList,
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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(`<div class="modal-content">
|
||||||
|
<h3 class="modal-title">${existing ? 'Редактировать' : 'Новое'} кардио</h3>
|
||||||
|
<div class="field-row">
|
||||||
|
<div class="field-group"><label>Тип</label><select id="m-ct">
|
||||||
|
<option value="вело" ${e.type==='вело'?'selected':''}>🚴 Вело</option>
|
||||||
|
<option value="ходьба" ${e.type==='ходьба'?'selected':''}>🚶 Ходьба</option>
|
||||||
|
<option value="бег" ${e.type==='бег'?'selected':''}>🏃 Бег</option>
|
||||||
|
<option value="эллипс" ${e.type==='эллипс'?'selected':''}>⚡ Эллипс</option>
|
||||||
|
<option value="гребля" ${e.type==='гребля'?'selected':''}>🚣 Гребля</option>
|
||||||
|
</select></div>
|
||||||
|
<div class="field-group"><label>Назначение</label><select id="m-cp">
|
||||||
|
<option value="warmup" ${e.purpose==='warmup'?'selected':''}>🔥 Разминка</option>
|
||||||
|
<option value="cooldown" ${e.purpose==='cooldown'?'selected':''}>🧘 Заминка</option>
|
||||||
|
<option value="separate" ${e.purpose==='separate'?'selected':''}>➕ Отдельная</option>
|
||||||
|
</select></div></div>
|
||||||
|
<div class="field-row-3">
|
||||||
|
<div class="field-group"><label>Минут</label>${stepperHtml('m-cd', e.duration??'', 1, 'мин', 'numeric')}</div>
|
||||||
|
<div class="field-group"><label>Дист.</label>${stepperHtml('m-cdi', e.distance??'', 0.1, 'км')}</div>
|
||||||
|
<div class="field-group"><label>Скор.</label>${stepperHtml('m-cs', e.speed??'', 0.5, 'км/ч')}</div></div>
|
||||||
|
<div class="field-row">
|
||||||
|
<div class="field-group"><label>Наклон</label>${stepperHtml('m-ci', e.incline??'', 0.5, '—')}</div>
|
||||||
|
<div class="field-group"><label>Сопр.</label>${stepperHtml('m-cr', e.resistance??'', 1, '—', 'numeric')}</div></div>
|
||||||
|
<div class="field-group"><label>Заметка</label><input type="text" id="m-cn" value="${e.notes||''}"></div>
|
||||||
|
<div class="btn-row">
|
||||||
|
<button class="btn btn-secondary" onclick="ModalBase.closeOnBg({target:document.querySelector('.modal-overlay')})">Отмена</button>
|
||||||
|
<button class="btn btn-success" onclick="CardioModal.submit()">Сохранить</button></div>
|
||||||
|
</div>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 };
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { showModal, closeModal } from './modal-base.js';
|
||||||
|
|
||||||
|
let _cb = null;
|
||||||
|
|
||||||
|
export function showEditExerciseDialog(exercise, callback) {
|
||||||
|
_cb = callback;
|
||||||
|
showModal(`
|
||||||
|
<div class="modal-content">
|
||||||
|
<h3 class="modal-title">Редактировать упражнение</h3>
|
||||||
|
<div class="field-group">
|
||||||
|
<label>Название</label>
|
||||||
|
<input type="text" id="m-edit-ex-name" value="${exercise.name}">
|
||||||
|
</div>
|
||||||
|
<div class="field-group">
|
||||||
|
<label>Тип тренировки</label>
|
||||||
|
<select id="m-edit-ex-type">
|
||||||
|
<option value="">тип не указан</option>
|
||||||
|
<option value="грудь-руки" ${exercise.workoutType==='грудь-руки'?'selected':''}>грудь-руки</option>
|
||||||
|
<option value="ноги" ${exercise.workoutType==='ноги'?'selected':''}>ноги</option>
|
||||||
|
<option value="спина-плечи" ${exercise.workoutType==='спина-плечи'?'selected':''}>спина-плечи</option>
|
||||||
|
<option value="круговая" ${exercise.workoutType==='круговая'?'selected':''}>круговая</option>
|
||||||
|
<option value="силовая" ${exercise.workoutType==='силовая'?'selected':''}>силовая</option>
|
||||||
|
<option value="многоповторная" ${exercise.workoutType==='многоповторная'?'selected':''}>многоповторная</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="btn-row">
|
||||||
|
<button class="btn btn-secondary" onclick="ModalBase.close()">Отмена</button>
|
||||||
|
<button class="btn btn-success" onclick="EditExModal.submit()">Сохранить</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 };
|
||||||
@@ -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(`
|
||||||
|
<div class="modal-content">
|
||||||
|
<h3 class="modal-title">📅 Дата тренировки</h3>
|
||||||
|
<div class="field-group">
|
||||||
|
<label>Начало</label>
|
||||||
|
<input type="datetime-local" id="m-w-start" value="${startValue}">
|
||||||
|
</div>
|
||||||
|
<div class="field-group">
|
||||||
|
<label>Окончание</label>
|
||||||
|
<input type="datetime-local" id="m-w-end" value="${endValue}">
|
||||||
|
</div>
|
||||||
|
<div class="btn-row">
|
||||||
|
<button class="btn btn-secondary" onclick="ModalBase.close()">Отмена</button>
|
||||||
|
<button class="btn btn-success" onclick="EditDateModal.submit()">Сохранить</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 };
|
||||||
@@ -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(`<div class="modal-content">
|
||||||
|
<h3 class="modal-title">Добавить упражнение</h3>
|
||||||
|
<div class="field-group">
|
||||||
|
<label>Название</label>
|
||||||
|
<input type="text" id="m-ex-name" placeholder="Начните вводить..." oninput="ExerciseModal.filter()" autocomplete="off">
|
||||||
|
<div id="m-ex-autocomplete" class="autocomplete-list"></div>
|
||||||
|
</div>
|
||||||
|
<div class="btn-row">
|
||||||
|
<button class="btn btn-secondary" onclick="ModalBase.closeOnBg({target:document.querySelector('.modal-overlay')})">Отмена</button>
|
||||||
|
<button class="btn" onclick="ExerciseModal.submit()">Добавить</button>
|
||||||
|
</div>
|
||||||
|
</div>`);
|
||||||
|
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 = '<div class="autocomplete-item text-muted">Нет совпадений</div>'; 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 `<div class="autocomplete-item" onclick="ExerciseModal.select('${name.replace(/'/g, "\\'")}')">${name}${count ? `<span class="freq">${count}×</span>` : ''}</div>`;
|
||||||
|
}).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 };
|
||||||
@@ -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(`<div class="modal-content">
|
||||||
|
<h3 class="modal-title">📏 Замеры ${isEdit ? '(ред.)' : ''}</h3>
|
||||||
|
<div class="field-group"><label>📅 Дата</label><input type="datetime-local" id="m-md" value="${dateVal}"></div>
|
||||||
|
<div class="field-row">
|
||||||
|
<div class="field-group"><label>⚖️ Вес</label>${stepperHtml('m-mw', src.weight??'', 0.1, 'кг')}</div>
|
||||||
|
<div class="field-group"><label>📏 Грудь</label>${stepperHtml('m-mc', src.chest??'', 1, 'см', 'numeric')}</div></div>
|
||||||
|
<div class="field-row">
|
||||||
|
<div class="field-group"><label>📏 Талия</label>${stepperHtml('m-mwa', src.waist??'', 1, 'см', 'numeric')}</div>
|
||||||
|
<div class="field-group"><label>📏 Бёдра</label>${stepperHtml('m-mh', src.hips??'', 1, 'см', 'numeric')}</div></div>
|
||||||
|
<div class="field-row">
|
||||||
|
<div class="field-group"><label>💪 Руки</label>${stepperHtml('m-ma', src.arms??'', 0.5, 'см')}</div>
|
||||||
|
<div class="field-group"><label>🦵 Ноги</label>${stepperHtml('m-ml', src.legs??'', 0.5, 'см')}</div></div>
|
||||||
|
<div class="btn-row">
|
||||||
|
<button class="btn btn-secondary" onclick="ModalBase.closeOnBg({target:document.querySelector('.modal-overlay')})">Отмена</button>
|
||||||
|
<button class="btn btn-success" onclick="MetricsModal.submit(${isEdit})">Сохранить</button></div>
|
||||||
|
</div>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 };
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
const container = () => document.getElementById('modal-container');
|
||||||
|
|
||||||
|
export function showModal(html) {
|
||||||
|
container().innerHTML = `<div class="modal-overlay" onclick="ModalBase.closeOnBg(event)">${html}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 `<div class="stepper-field">
|
||||||
|
<button type="button" class="stepper-btn" onclick="ModalBase.stepValue('${id}', -${step})">−</button>
|
||||||
|
<input type="number" id="${id}" step="${step}" value="${v}" placeholder="${placeholder}" inputmode="${inputmode}">
|
||||||
|
<button type="button" class="stepper-btn" onclick="ModalBase.stepValue('${id}', ${step})">+</button>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 };
|
||||||
@@ -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 => `<div class="history-hint-row">
|
||||||
|
<span class="history-hint-date">${formatShortDate(h.date)}</span>
|
||||||
|
<span class="history-hint-sets">${h.sets.map(formatSet).join(', ')}</span>
|
||||||
|
</div>`).join('');
|
||||||
|
historyHtml = `<div class="history-hint"><div class="history-hint-title">📊 Прошлые тренировки</div>${rows}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
showModal(`<div class="modal-content">
|
||||||
|
<h3 class="modal-title">${isEdit ? 'Редактировать' : 'Новый'} подход</h3>
|
||||||
|
${historyHtml}
|
||||||
|
<div class="field-group"><label style="display:flex;align-items:center;gap:8px;">
|
||||||
|
<input type="checkbox" id="m-set-dropset" ${isDropset ? 'checked' : ''} onchange="SetModal.toggleDropset()" style="width:auto;">
|
||||||
|
Это дроп-сет</label></div>
|
||||||
|
<div id="m-set-normal" class="${isDropset ? 'hidden' : ''}">
|
||||||
|
<div class="field-row">
|
||||||
|
<div class="field-group"><label>Вес (кг)</label>${stepperHtml('m-set-weight', existingSet?.weight ?? '', 0.5, 'Вес')}</div>
|
||||||
|
<div class="field-group"><label>Повторения</label>${stepperHtml('m-set-reps', existingSet?.reps ?? '', 1, 'Повт.', 'numeric')}</div>
|
||||||
|
</div></div>
|
||||||
|
<div id="m-set-dropset-block" class="${isDropset ? '' : 'hidden'}">
|
||||||
|
<div id="m-subsets-list"></div>
|
||||||
|
<button class="btn btn-ghost mt-10" style="width:100%;" onclick="SetModal.addSubset()">+ Сбрасывание</button></div>
|
||||||
|
<div class="field-group"><label>Оценка (1-5)</label>
|
||||||
|
<div class="rating-grid" id="m-rating-grid">
|
||||||
|
${[1,2,3,4,5].map(r => `<button class="rating-btn ${existingSet?.rating===r?'active':''}" data-rating="${r}" onclick="SetModal.rating(${r})">${r}</button>`).join('')}
|
||||||
|
</div></div>
|
||||||
|
<div class="btn-row">
|
||||||
|
<button class="btn btn-secondary" onclick="ModalBase.closeOnBg({target:document.querySelector('.modal-overlay')})">Отмена</button>
|
||||||
|
<button class="btn btn-success" onclick="SetModal.submit()">Сохранить</button></div>
|
||||||
|
</div>`);
|
||||||
|
|
||||||
|
_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) => `
|
||||||
|
<div class="stepper-row">
|
||||||
|
<div class="stepper-field">
|
||||||
|
<button type="button" class="stepper-btn" onclick="SetModal.stepSubset(${i}, 'weight', -0.5)">−</button>
|
||||||
|
<input type="number" id="m-sw-${i}" step="0.5" value="${s.weight || ''}" placeholder="Вес" inputmode="decimal" oninput="SetModal.updateSubset(${i}, 'weight', this.value)">
|
||||||
|
<button type="button" class="stepper-btn" onclick="SetModal.stepSubset(${i}, 'weight', 0.5)">+</button>
|
||||||
|
</div>
|
||||||
|
<div class="stepper-field">
|
||||||
|
<button type="button" class="stepper-btn" onclick="SetModal.stepSubset(${i}, 'reps', -1)">−</button>
|
||||||
|
<input type="number" id="m-sr-${i}" step="1" value="${s.reps || ''}" placeholder="Повт." inputmode="numeric" oninput="SetModal.updateSubset(${i}, 'reps', this.value)">
|
||||||
|
<button type="button" class="stepper-btn" onclick="SetModal.stepSubset(${i}, 'reps', 1)">+</button>
|
||||||
|
</div>
|
||||||
|
<button class="icon-btn danger" onclick="SetModal.removeSubset(${i})">✕</button>
|
||||||
|
</div>
|
||||||
|
`).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
|
||||||
|
};
|
||||||
@@ -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 ? '' : `
|
||||||
|
<div class="set-actions">
|
||||||
|
<button class="icon-btn" onclick="CL.editCardio('${workoutId}', '${c.id}')">✎</button>
|
||||||
|
<button class="icon-btn danger" onclick="CL.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;
|
||||||
|
}
|
||||||
@@ -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 = `
|
||||||
|
<div class="exercise-header ${isExpanded ? 'expanded' : ''}" onclick="EL.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(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 = () => 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;
|
||||||
|
}
|
||||||
@@ -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 = `
|
||||||
|
<div class="empty-state">
|
||||||
|
<h3>🏋️ Нет тренировок</h3>
|
||||||
|
<p>Начните первую тренировку</p>
|
||||||
|
<button class="btn" id="btn-start-first">▶ Начать тренировку</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
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));
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
export function renderSetRow(workoutId, exerciseId, set, index, isReadOnly = false) {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
|
||||||
|
const actionsHtml = isReadOnly ? '' : `
|
||||||
|
<div class="set-actions">
|
||||||
|
<button class="icon-btn" onclick="SL.editSet('${workoutId}', '${exerciseId}', '${set.id}')">✎</button>
|
||||||
|
<button class="icon-btn danger" onclick="SL.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;
|
||||||
|
}
|
||||||
@@ -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 = '<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(); WL.editWorkoutDate('${w.id}')">
|
||||||
|
<span class="edit-icon">✎</span>
|
||||||
|
</span>`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
div.innerHTML = `
|
||||||
|
<div class="${headerClass}" onclick="WL.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(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 = `
|
||||||
|
<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.saveAndRender();">
|
||||||
|
<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);
|
||||||
|
|
||||||
|
// Кнопки добавления
|
||||||
|
if (isActive && !isReadOnly) {
|
||||||
|
const addSection = document.createElement('div');
|
||||||
|
addSection.className = 'add-section';
|
||||||
|
addSection.innerHTML = `
|
||||||
|
<button class="btn btn-secondary" onclick="EL.addExercise('${w.id}')">+ Упражнение</button>
|
||||||
|
<button class="btn btn-secondary" onclick="CL.addCardio('${w.id}')">+ Кардио</button>
|
||||||
|
`;
|
||||||
|
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 = `
|
||||||
|
<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;
|
||||||
|
window.App.saveAndRender();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
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="WL.finishWorkout('${w.id}')">⏹ Закончить тренировку</button>`;
|
||||||
|
} else {
|
||||||
|
finishBlock.innerHTML = `
|
||||||
|
<h3>Тренировка завершена</h3>
|
||||||
|
<p class="text-muted text-small">До синхронизации можно редактировать</p>
|
||||||
|
<button class="btn btn-danger" onclick="WL.deleteWorkout('${w.id}')">🗑 Удалить</button>`;
|
||||||
|
}
|
||||||
|
wrap.appendChild(finishBlock);
|
||||||
|
|
||||||
|
return wrap;
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}`);
|
||||||
|
}
|
||||||
@@ -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())}`;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
@@ -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); }
|
||||||
@@ -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; }
|
||||||
@@ -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; }
|
||||||
-30
@@ -1,30 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="ru">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
|
||||||
<title>Gym Tracker</title>
|
|
||||||
<link rel="stylesheet" href="styles.css">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<header>
|
|
||||||
<div>
|
|
||||||
<h2 id="page-title">Gym Tracker</h2>
|
|
||||||
<div id="sync-status" class="sync-status">● Локально</div>
|
|
||||||
</div>
|
|
||||||
<div class="header-actions">
|
|
||||||
<button class="icon-btn header-btn" onclick="App.showBodyMetrics()" title="Замеры">📏</button>
|
|
||||||
<button class="icon-btn header-btn" onclick="App.showSettings()" title="Настройки">⚙️</button>
|
|
||||||
<button class="btn btn-small btn-secondary" onclick="App.sync()">🔄</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main id="app" class="container"></main>
|
|
||||||
<div id="modal-container"></div>
|
|
||||||
|
|
||||||
<!-- ВАЖНО: modal.js загружается ПЕРВЫМ, так как app.js использует его при инициализации -->
|
|
||||||
<script src="modal.js"></script>
|
|
||||||
<script src="app.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,602 +0,0 @@
|
|||||||
const Modal = {
|
|
||||||
show(html) {
|
|
||||||
const container = document.getElementById('modal-container');
|
|
||||||
container.innerHTML = `<div class="modal-overlay" onclick="Modal.closeOnBg(event)">${html}</div>`;
|
|
||||||
},
|
|
||||||
|
|
||||||
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 `
|
|
||||||
<div class="stepper-field">
|
|
||||||
<button type="button" class="stepper-btn" onclick="Modal.stepValue('${id}', -${step})">−</button>
|
|
||||||
<input type="number" id="${id}" step="${step}" value="${displayValue}"
|
|
||||||
placeholder="${placeholder}" inputmode="${inputmode}"
|
|
||||||
onchange="Modal._onStepperChange('${id}')">
|
|
||||||
<button type="button" class="stepper-btn" onclick="Modal.stepValue('${id}', ${step})">+</button>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
},
|
|
||||||
|
|
||||||
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(`
|
|
||||||
<div class="modal-content">
|
|
||||||
<h3 class="modal-title">Добавить упражнение</h3>
|
|
||||||
<div class="field-group">
|
|
||||||
<label>Название</label>
|
|
||||||
<input type="text" id="m-ex-name" placeholder="Начните вводить..."
|
|
||||||
oninput="Modal._filterExercises()" autocomplete="off">
|
|
||||||
<div id="m-ex-autocomplete" class="autocomplete-list"></div>
|
|
||||||
</div>
|
|
||||||
<div class="btn-row">
|
|
||||||
<button class="btn btn-secondary" onclick="Modal.close()">Отмена</button>
|
|
||||||
<button class="btn" onclick="Modal._submitExercise()">Добавить</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`);
|
|
||||||
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 = '<div class="autocomplete-item text-muted">Нет совпадений — можно ввести новое</div>';
|
|
||||||
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 `
|
|
||||||
<div class="autocomplete-item" onclick="Modal._selectExercise('${name.replace(/'/g, "\\'")}')">
|
|
||||||
${name}
|
|
||||||
${count > 0 ? `<span class="freq">${count}×</span>` : ''}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}).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 `<div class="history-hint-row">
|
|
||||||
<span class="history-hint-date">${date}</span>
|
|
||||||
<span class="history-hint-sets">${setsStr}</span>
|
|
||||||
</div>`;
|
|
||||||
}).join('');
|
|
||||||
historyHtml = `
|
|
||||||
<div class="history-hint">
|
|
||||||
<div class="history-hint-title">📊 Прошлые тренировки</div>
|
|
||||||
${rows}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.show(`
|
|
||||||
<div class="modal-content">
|
|
||||||
<h3 class="modal-title">${isEdit ? 'Редактировать' : 'Новый'} подход</h3>
|
|
||||||
|
|
||||||
${historyHtml}
|
|
||||||
|
|
||||||
<div class="field-group">
|
|
||||||
<label style="display:flex; align-items:center; gap:8px;">
|
|
||||||
<input type="checkbox" id="m-set-dropset" ${isDropset ? 'checked' : ''}
|
|
||||||
onchange="Modal._toggleDropsetMode()" style="width:auto;">
|
|
||||||
Это дроп-сет (несколько сбрасываний веса)
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="m-set-normal" class="${isDropset ? 'hidden' : ''}">
|
|
||||||
<div class="field-row">
|
|
||||||
<div class="field-group">
|
|
||||||
<label>Вес (кг)</label>
|
|
||||||
${this.stepperHtml('m-set-weight', existingSet?.weight ?? '', 0.5, 'Вес', 'decimal')}
|
|
||||||
</div>
|
|
||||||
<div class="field-group">
|
|
||||||
<label>Повторения</label>
|
|
||||||
${this.stepperHtml('m-set-reps', existingSet?.reps ?? '', 1, 'Повт.', 'numeric')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="m-set-dropset-block" class="${isDropset ? '' : 'hidden'}">
|
|
||||||
<div id="m-subsets-list"></div>
|
|
||||||
<button class="btn btn-ghost mt-10" style="width:100%;"
|
|
||||||
onclick="Modal._addSubset()">+ Добавить сбрасывание</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field-group">
|
|
||||||
<label>Оценка усилия (1 — легко, 5 — отказ)</label>
|
|
||||||
<div class="rating-grid" id="m-rating-grid">
|
|
||||||
${[1,2,3,4,5].map(r => `
|
|
||||||
<button class="rating-btn ${existingSet?.rating === r ? 'active' : ''}"
|
|
||||||
data-rating="${r}" onclick="Modal._selectRating(${r})">${r}</button>
|
|
||||||
`).join('')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="btn-row">
|
|
||||||
<button class="btn btn-secondary" onclick="Modal.close()">Отмена</button>
|
|
||||||
<button class="btn btn-success" onclick="Modal._submitSet()">Сохранить</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`);
|
|
||||||
|
|
||||||
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) => `
|
|
||||||
<div class="stepper-row">
|
|
||||||
${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}"`)}
|
|
||||||
<button class="icon-btn danger" onclick="Modal._removeSubset(${i})">✕</button>
|
|
||||||
</div>
|
|
||||||
`).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(`
|
|
||||||
<div class="modal-content">
|
|
||||||
<h3 class="modal-title">${isEdit ? 'Редактировать' : 'Новое'} кардио</h3>
|
|
||||||
|
|
||||||
<div class="field-row">
|
|
||||||
<div class="field-group">
|
|
||||||
<label>Тип</label>
|
|
||||||
<select id="m-cardio-type">
|
|
||||||
<option value="вело" ${existing?.type==='вело'?'selected':''}>🚴 Вело</option>
|
|
||||||
<option value="ходьба" ${existing?.type==='ходьба'?'selected':''}>🚶 Ходьба</option>
|
|
||||||
<option value="бег" ${existing?.type==='бег'?'selected':''}>🏃 Бег</option>
|
|
||||||
<option value="эллипс" ${existing?.type==='эллипс'?'selected':''}>⚡ Эллипс</option>
|
|
||||||
<option value="гребля" ${existing?.type==='гребля'?'selected':''}>🚣 Гребля</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="field-group">
|
|
||||||
<label>Назначение</label>
|
|
||||||
<select id="m-cardio-purpose">
|
|
||||||
<option value="warmup" ${existing?.purpose==='warmup'?'selected':''}>🔥 Разминка</option>
|
|
||||||
<option value="cooldown" ${existing?.purpose==='cooldown'?'selected':''}>🧘 Заминка</option>
|
|
||||||
<option value="separate" ${existing?.purpose==='separate'?'selected':''}>➕ Отдельная</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field-row-3">
|
|
||||||
<div class="field-group">
|
|
||||||
<label>Минут</label>
|
|
||||||
${this.stepperHtml('m-cardio-duration', existing?.duration ?? '', 1, 'мин', 'numeric')}
|
|
||||||
</div>
|
|
||||||
<div class="field-group">
|
|
||||||
<label>Дистанция</label>
|
|
||||||
${this.stepperHtml('m-cardio-distance', existing?.distance ?? '', 0.1, 'км', 'decimal')}
|
|
||||||
</div>
|
|
||||||
<div class="field-group">
|
|
||||||
<label>Скорость</label>
|
|
||||||
${this.stepperHtml('m-cardio-speed', existing?.speed ?? '', 0.5, 'км/ч', 'decimal')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field-row">
|
|
||||||
<div class="field-group">
|
|
||||||
<label>Наклон</label>
|
|
||||||
${this.stepperHtml('m-cardio-incline', existing?.incline ?? '', 0.5, '—', 'decimal')}
|
|
||||||
</div>
|
|
||||||
<div class="field-group">
|
|
||||||
<label>Сопротивление</label>
|
|
||||||
${this.stepperHtml('m-cardio-resistance', existing?.resistance ?? '', 1, '—', 'numeric')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field-group">
|
|
||||||
<label>Заметка</label>
|
|
||||||
<input type="text" id="m-cardio-notes" value="${existing?.notes || ''}"
|
|
||||||
placeholder="Например: тяжёло после 20 минуты">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="btn-row">
|
|
||||||
<button class="btn btn-secondary" onclick="Modal.close()">Отмена</button>
|
|
||||||
<button class="btn btn-success" onclick="Modal._submitCardio()">Сохранить</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`);
|
|
||||||
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(`
|
|
||||||
<div class="modal-content">
|
|
||||||
<h3 class="modal-title">📏 Замеры тела ${isEdit ? '(редактирование)' : ''}</h3>
|
|
||||||
|
|
||||||
<div class="field-group">
|
|
||||||
<label>📅 Дата замера</label>
|
|
||||||
<input type="datetime-local" id="m-metric-date" value="${dateValue}">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field-row">
|
|
||||||
<div class="field-group">
|
|
||||||
<label>⚖️ Вес (кг)</label>
|
|
||||||
${this.stepperHtml('m-metric-weight', source?.weight ?? '', 0.1, 'кг', 'decimal')}
|
|
||||||
</div>
|
|
||||||
<div class="field-group">
|
|
||||||
<label>📏 Грудь (см)</label>
|
|
||||||
${this.stepperHtml('m-metric-chest', source?.chest ?? '', 1, 'см', 'numeric')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field-row">
|
|
||||||
<div class="field-group">
|
|
||||||
<label>📏 Талия (см)</label>
|
|
||||||
${this.stepperHtml('m-metric-waist', source?.waist ?? '', 1, 'см', 'numeric')}
|
|
||||||
</div>
|
|
||||||
<div class="field-group">
|
|
||||||
<label>📏 Бёдра (см)</label>
|
|
||||||
${this.stepperHtml('m-metric-hips', source?.hips ?? '', 1, 'см', 'numeric')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field-row">
|
|
||||||
<div class="field-group">
|
|
||||||
<label>💪 Руки (см)</label>
|
|
||||||
${this.stepperHtml('m-metric-arms', source?.arms ?? '', 0.5, 'см', 'decimal')}
|
|
||||||
</div>
|
|
||||||
<div class="field-group">
|
|
||||||
<label>🦵 Ноги (см)</label>
|
|
||||||
${this.stepperHtml('m-metric-legs', source?.legs ?? '', 0.5, 'см', 'decimal')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="btn-row">
|
|
||||||
<button class="btn btn-secondary" onclick="Modal.close()">Отмена</button>
|
|
||||||
<button class="btn btn-success" onclick="Modal._submitMetrics(${isEdit})">Сохранить</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`);
|
|
||||||
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(`
|
|
||||||
<div class="modal-content">
|
|
||||||
<h3 class="modal-title">Редактировать упражнение</h3>
|
|
||||||
<div class="field-group">
|
|
||||||
<label>Название</label>
|
|
||||||
<input type="text" id="m-edit-ex-name" value="${exercise.name}">
|
|
||||||
</div>
|
|
||||||
<div class="field-group">
|
|
||||||
<label>Тип тренировки</label>
|
|
||||||
<select id="m-edit-ex-type">
|
|
||||||
<option value="">тип не указан</option>
|
|
||||||
<option value="грудь-руки" ${exercise.workoutType==='грудь-руки'?'selected':''}>грудь-руки</option>
|
|
||||||
<option value="ноги" ${exercise.workoutType==='ноги'?'selected':''}>ноги</option>
|
|
||||||
<option value="спина-плечи" ${exercise.workoutType==='спина-плечи'?'selected':''}>спина-плечи</option>
|
|
||||||
<option value="круговая" ${exercise.workoutType==='круговая'?'selected':''}>круговая</option>
|
|
||||||
<option value="силовая" ${exercise.workoutType==='силовая'?'selected':''}>силовая</option>
|
|
||||||
<option value="многоповторная" ${exercise.workoutType==='многоповторная'?'selected':''}>многоповторная</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="btn-row">
|
|
||||||
<button class="btn btn-secondary" onclick="Modal.close()">Отмена</button>
|
|
||||||
<button class="btn btn-success" onclick="Modal._submitEditExercise()">Сохранить</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`);
|
|
||||||
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(`
|
|
||||||
<div class="modal-content">
|
|
||||||
<h3 class="modal-title">📅 Дата тренировки</h3>
|
|
||||||
<div class="field-group">
|
|
||||||
<label>Начало</label>
|
|
||||||
<input type="datetime-local" id="m-w-start" value="${startValue}">
|
|
||||||
</div>
|
|
||||||
<div class="field-group">
|
|
||||||
<label>Окончание</label>
|
|
||||||
<input type="datetime-local" id="m-w-end" value="${endValue}">
|
|
||||||
</div>
|
|
||||||
<div class="btn-row">
|
|
||||||
<button class="btn btn-secondary" onclick="Modal.close()">Отмена</button>
|
|
||||||
<button class="btn btn-success" onclick="Modal._submitWorkoutDate()">Сохранить</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`);
|
|
||||||
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,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
-571
@@ -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; }
|
|
||||||
Reference in New Issue
Block a user