diff --git a/backend/gym.db b/backend/gym.db index 0ecad92..6fc8292 100644 Binary files a/backend/gym.db and b/backend/gym.db differ diff --git a/backend/main.py b/backend/main.py index 4eaa16a..8f02231 100644 --- a/backend/main.py +++ b/backend/main.py @@ -14,7 +14,7 @@ import routers.sync Base.metadata.create_all(bind=engine) app = FastAPI( - title="Gym Tracker API", + title="Gym Bro API", description="API для трекера тренировок с офлайн-синхронизацией", version="1.0.0" ) diff --git a/frontend/js/app.js b/frontend/js/app.js index 77bca33..d30456e 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -16,6 +16,7 @@ import './modals/exercise-modal.js'; import './modals/set-modal.js'; import './modals/cardio-modal.js'; import './modals/metrics-modal.js'; +import './modals/client-select-modal.js'; import './modals/edit-exercise-modal.js'; import './modals/edit-workout-date-modal.js'; @@ -23,12 +24,12 @@ function renderHeader() { const modeLabel = state.mode === 'trainer' ? '👨‍🏫 Тренер' : '👤 Личный'; document.getElementById('app-header').innerHTML = `
-

Gym Tracker (${modeLabel})

+

Gym Bro (${modeLabel})

● Локально
- +
@@ -45,20 +46,20 @@ async function init() { await initAppData(); } -// Глобальные хелперы window.App = { getWorkout: (id) => state.workouts.find(w => w.id === id), saveAndRender: () => { saveState(); }, toggleMode: () => { state.mode = state.mode === 'personal' ? 'trainer' : 'personal'; - state.currentClientId = state.mode === 'personal' ? null : state.currentClientId; saveState(); renderHeader(); renderMainScreen(); - alert(`Режим изменён на: ${state.mode === 'trainer' ? 'Тренер' : 'Личный трекер'}`); - } + }, + backToMain: () => renderMainScreen(), }; window.AppSync = { sync }; -window.App.backToMain = () => renderMainScreen(); + +// Делаем renderMainScreen доступным глобально +window.renderMainScreen = renderMainScreen; document.addEventListener('DOMContentLoaded', init); diff --git a/frontend/js/logic/exercise.js b/frontend/js/logic/exercise.js index 5bc1780..ac70729 100644 --- a/frontend/js/logic/exercise.js +++ b/frontend/js/logic/exercise.js @@ -1,30 +1,54 @@ import { state, saveState, genId } from '../state.js'; import { renderMainScreen } from '../render/main-screen.js'; import { showExerciseDialog } from '../modals/exercise-modal.js'; -import { fetchExerciseHistory } from '../api.js'; + +function normalizeName(name) { + return String(name || '').trim().toLowerCase(); +} + +function normalizeClientId(id) { + if (id === undefined || id === null || id === '') return null; + return String(id); +} + +function isSameClient(a, b) { + return normalizeClientId(a) === normalizeClientId(b); +} export function addExercise(workoutId) { const w = state.workouts.find(x => x.id === workoutId); + if (!w) return; + showExerciseDialog(w.type, (name) => { if (!name) return; - const ex = { id: genId(), name, sets: [] }; + + 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 normalizedName = normalizeName(name); + const exists = state.exerciseRef.find( - e => e.name.trim().toLowerCase() === normalizedName + e => normalizeName(e.name) === normalizedName ); + if (!exists) { state.exerciseRef.push({ id: genId(), - name: name.trim(), + name: String(name).trim(), workoutType, }); } else if (!exists.workoutType && workoutType) { @@ -35,48 +59,68 @@ export function addToRef(name, 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); + if (!w) return; + w.exercises = w.exercises.filter(e => e.id !== exerciseId); + saveState(); renderMainScreen(); } -export async function getExerciseHistory(exerciseName) { - // Пытаемся получить с бэкенда - try { - const history = await fetchExerciseHistory(exerciseName, state.currentClientId); - if (history && history.length > 0) { - return history.map(h => ({ - date: h.date, - sets: [{ // Адаптируем формат бэкенда под формат фронта - isDropset: h.is_dropset, - weight: h.weight, - reps: h.reps, - subSets: h.is_dropset ? [{weight: h.weight, reps: h.reps}] : undefined // Упрощение для отображения - }] - })); - } - } catch (e) { - console.warn('Не удалось загрузить историю с бэкенда, используем локальную', e); - } +/** + * Локальная история упражнения. + * + * @param {string} exerciseName + * @param {string|number|null} clientId - если тренировка принадлежит клиенту, история берётся только для этого клиента + * @param {string|number|null} excludeWorkoutId - чтобы не показывать текущую тренировку как "прошлую" + */ +export function getExerciseHistory(exerciseName, clientId = null, excludeWorkoutId = null) { + const normalizedName = normalizeName(exerciseName); + const targetClient = normalizeClientId(clientId); - // Fallback на локальные данные - 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 }); + // Не показываем текущую тренировку в истории + if (excludeWorkoutId && String(w.id) === String(excludeWorkoutId)) { + continue; + } + + // Если у тренировки есть клиент — сравниваем клиентов + // Если клиента нет — показываем только личные тренировки без клиента + if (!isSameClient(w.client_id, targetClient)) { + continue; + } + + for (const ex of w.exercises || []) { + if ( + normalizeName(ex.name) === normalizedName && + Array.isArray(ex.sets) && + 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 }; +window.EL = { + addExercise, + toggleExercise, + deleteExercise, +}; diff --git a/frontend/js/logic/metrics.js b/frontend/js/logic/metrics.js index aed6ca8..2edaf47 100644 --- a/frontend/js/logic/metrics.js +++ b/frontend/js/logic/metrics.js @@ -3,9 +3,13 @@ import { renderMainScreen } from '../render/main-screen.js'; import { showBodyMetricsDialog } from '../modals/metrics-modal.js'; export function showBodyMetrics() { - showBodyMetricsDialog(state.bodyMetrics, (newMetric) => { + // В личном режиме — замеры без клиента + const personalMetrics = state.bodyMetrics.filter(m => !m.client_id); + + showBodyMetricsDialog(personalMetrics, (newMetric) => { if (!newMetric) return; newMetric.id = genId(); + newMetric.client_id = null; // личный замер state.bodyMetrics.unshift(newMetric); state.bodyMetrics.sort((a, b) => new Date(b.date) - new Date(a.date)); saveState(); @@ -22,10 +26,10 @@ export function editBodyMetric(metricId) { 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(); + window.SettingsLogic._renderPersonalMetricsList(); } else { renderMainScreen(); } @@ -38,7 +42,7 @@ export function deleteMetric(id) { saveState(); const list = document.getElementById('body-metrics-list'); if (list && typeof window.SettingsLogic !== 'undefined') { - window.SettingsLogic._renderBodyMetricsList(); + window.SettingsLogic._renderPersonalMetricsList(); } else { renderMainScreen(); } diff --git a/frontend/js/logic/set.js b/frontend/js/logic/set.js index c40943e..faddb3c 100644 --- a/frontend/js/logic/set.js +++ b/frontend/js/logic/set.js @@ -5,15 +5,31 @@ import { getExerciseHistory } from './exercise.js'; export function addSet(workoutId, exerciseId) { const w = state.workouts.find(x => x.id === workoutId); + if (!w) return; + const ex = w.exercises.find(e => e.id === exerciseId); + if (!ex) return; + + if (!Array.isArray(ex.sets)) { + ex.sets = []; + } + const lastSet = ex.sets[ex.sets.length - 1]; - const history = getExerciseHistory(ex.name); + + const history = getExerciseHistory( + ex.name, + w.client_id ?? null, + w.id + ); showSetDialog(lastSet, history, (setData) => { if (!setData) return; + setData.id = genId(); setData.timestamp = new Date().toISOString(); + ex.sets.push(setData); + saveState(); renderMainScreen(); }); @@ -21,13 +37,29 @@ export function addSet(workoutId, exerciseId) { export function editSet(workoutId, exerciseId, setId) { const w = state.workouts.find(x => x.id === workoutId); + if (!w) return; + const ex = w.exercises.find(e => e.id === exerciseId); + if (!ex) return; + + if (!Array.isArray(ex.sets)) { + ex.sets = []; + } + const set = ex.sets.find(s => s.id === setId); - const history = getExerciseHistory(ex.name); + if (!set) return; + + const history = getExerciseHistory( + ex.name, + w.client_id ?? null, + w.id + ); showSetDialog(set, history, (setData) => { if (!setData) return; + Object.assign(set, setData); + saveState(); renderMainScreen(); }); @@ -35,11 +67,25 @@ export function editSet(workoutId, exerciseId, setId) { export function deleteSet(workoutId, exerciseId, setId) { if (!confirm('Удалить подход?')) return; + const w = state.workouts.find(x => x.id === workoutId); + if (!w) return; + const ex = w.exercises.find(e => e.id === exerciseId); + if (!ex) return; + + if (!Array.isArray(ex.sets)) { + ex.sets = []; + } + ex.sets = ex.sets.filter(s => s.id !== setId); + saveState(); renderMainScreen(); } -window.SL = { addSet, editSet, deleteSet }; +window.SL = { + addSet, + editSet, + deleteSet, +}; diff --git a/frontend/js/logic/settings.js b/frontend/js/logic/settings.js index 3561724..96efaa3 100644 --- a/frontend/js/logic/settings.js +++ b/frontend/js/logic/settings.js @@ -1,5 +1,7 @@ -import { state, saveState } from '../state.js'; +import { state, saveState, CLIENT_COLORS, DEFAULT_COLOR, genId, generateUUID } from '../state.js'; import { showEditExerciseDialog } from '../modals/edit-exercise-modal.js'; +import { showBodyMetricsDialog } from '../modals/metrics-modal.js'; +import { showModal } from '../modals/modal-base.js'; export function showSettings() { const app = document.getElementById('app'); @@ -12,6 +14,14 @@ export function showSettings() {

⚙️ Настройки

+ +
+

👥 Клиенты

+

Управление клиентами и их замерами. Цвет клиента используется для подсветки тренировок.

+ +
+
+

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

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

@@ -20,30 +30,67 @@ export function showSettings() {
+
-

📏 Замеры тела

+

📏 Личные замеры

+

Замеры без привязки к клиенту (для личного режима).

+
+

🗑 Данные

`; app.appendChild(screen); + + _renderClientsList(); _renderRefExercises(); - _renderBodyMetricsList(); + _renderPersonalMetricsList(); +} + +export function _renderPersonalMetricsList() { + const list = document.getElementById('body-metrics-list'); + if (!list) return; + + const personalMetrics = state.bodyMetrics.filter(m => !m.client_id); + + if (personalMetrics.length === 0) { + list.innerHTML = '

Нет личных замеров

'; + return; + } + + list.innerHTML = personalMetrics.map(m => { + const date = new Date(m.date); + const dateStr = date.toLocaleDateString('ru-RU') + ' ' + date.toLocaleTimeString('ru-RU', {hour:'2-digit', minute:'2-digit'}); + const parts = []; + if (m.weight) parts.push(`⚖️ ${m.weight} кг`); + if (m.chest) parts.push(`📏 ${m.chest} см`); + if (m.waist) parts.push(`📏 ${m.waist} см`); + if (m.hips) parts.push(`📏 ${m.hips} см`); + if (m.arms) parts.push(`💪 ${m.arms} см`); + if (m.legs) parts.push(`🦵 ${m.legs} см`); + return ` +
+
+
${dateStr}
+
${parts.join(' • ') || '—'}
+
+
+ + +
+
+ `; + }).join(''); } export function addRefExercise() { @@ -146,7 +193,6 @@ export function _renderRefExercises() { }).join(''); } -// Добавим отображение типов замеров в настройки (для тренера) export function _renderMetricTypes() { const container = document.getElementById('metric-types-list'); if (!container) return; @@ -196,9 +242,257 @@ export function _renderBodyMetricsList() { }).join(''); } +export function showAddClientModal(editingClient = null) { + const isEdit = !!editingClient; + const client = editingClient || { full_name: '', birth_date: '', goal: '', color: DEFAULT_COLOR }; + + const paletteHtml = CLIENT_COLORS.map(color => ` +
+ `).join(''); + + const html = ` + + `; + showModal(html); + window._tempClientColor = client.color; +} + +export function selectClientColor(color) { + window._tempClientColor = color; + document.querySelectorAll('#m-client-palette .color-swatch').forEach(el => { + el.classList.toggle('selected', el.style.background === color || + rgbToHex(el.style.backgroundColor) === color.toLowerCase()); + }); +} + +function rgbToHex(rgb) { + if (rgb.startsWith('#')) return rgb.toLowerCase(); + const match = rgb.match(/\d+/g); + if (!match) return rgb; + return '#' + match.slice(0, 3).map(x => parseInt(x).toString(16).padStart(2, '0')).join(''); +} + +export function submitClient(editingId) { + const name = document.getElementById('m-client-name').value.trim(); + if (!name) { alert('Введите ФИО'); return; } + + const color = window._tempClientColor || DEFAULT_COLOR; + + if (editingId) { + // Редактирование + const client = state.clients.find(c => c.id == editingId); + if (client) { + client.full_name = name; + client.birth_date = document.getElementById('m-client-dob').value || null; + client.goal = document.getElementById('m-client-goal').value.trim(); + client.color = color; + } + } else { + // Создание + const newClient = { + id: genId(), + sync_id: generateUUID(), + full_name: name, + birth_date: document.getElementById('m-client-dob').value || null, + goal: document.getElementById('m-client-goal').value.trim(), + color: color, + is_active: 1, + }; + state.clients.push(newClient); + } + + saveState(); + window.ModalBase.closeModal(); + _renderClientsList(); + window.renderMainScreen(); // Обновляем цвета тренировок +} + +export function deleteClient(id) { + if (!confirm('Удалить клиента? Тренировки останутся, но без привязки.')) return; + state.clients = state.clients.filter(c => c.id != id); + // Отвязываем тренировки + state.workouts.forEach(w => { + if (w.client_id == id) w.client_id = null; + }); + // Отвязываем замеры + state.bodyMetrics.forEach(m => { + if (m.client_id == id) m.client_id = null; + }); + saveState(); + _renderClientsList(); + window.renderMainScreen(); +} + +export function _renderClientsList() { + const list = document.getElementById('clients-list'); + if (!list) return; + + if (state.clients.length === 0) { + list.innerHTML = '

Нет клиентов. Добавьте первого!

'; + return; + } + + list.innerHTML = state.clients.map(c => { + // Считаем замеры этого клиента + const clientMetrics = state.bodyMetrics.filter(m => m.client_id == c.id); + const lastMetric = clientMetrics[0]; // уже отсортированы по дате + + let metricsPreview = 'Нет замеров'; + if (lastMetric) { + const parts = []; + if (lastMetric.weight) parts.push(`⚖️ ${lastMetric.weight} кг`); + if (lastMetric.chest) parts.push(`📏 ${lastMetric.chest} см`); + metricsPreview = parts.join(' • ') || 'Замеры без значений'; + } + + // Считаем тренировки + const clientWorkouts = state.workouts.filter(w => w.client_id == c.id); + + return ` +
+
+
${c.full_name}
+
${c.goal || 'Цель не указана'}
+
+ 🏋️ ${clientWorkouts.length} трен. • 📏 ${clientMetrics.length} замеров +
+ ${lastMetric ? `
Последний: ${metricsPreview}
` : ''} +
+
+ + + +
+
+ `; + }).join(''); +} + +export function showClientMetrics(clientId) { + const client = state.clients.find(c => c.id == clientId); + if (!client) return; + + const clientMetrics = state.bodyMetrics + .filter(m => m.client_id == clientId) + .sort((a, b) => new Date(b.date) - new Date(a.date)); + + const metricsHtml = clientMetrics.length === 0 + ? '

Нет замеров

' + : clientMetrics.map(m => { + const date = new Date(m.date); + const dateStr = date.toLocaleDateString('ru-RU') + ' ' + date.toLocaleTimeString('ru-RU', {hour:'2-digit', minute:'2-digit'}); + const parts = []; + if (m.weight) parts.push(`⚖️ ${m.weight} кг`); + if (m.chest) parts.push(`📏 ${m.chest} см`); + if (m.waist) parts.push(`📏 ${m.waist} см`); + if (m.hips) parts.push(`📏 ${m.hips} см`); + if (m.arms) parts.push(`💪 ${m.arms} см`); + if (m.legs) parts.push(`🦵 ${m.legs} см`); + return ` +
+
+
${dateStr}
+
${parts.join(' • ') || '—'}
+
+
+ + +
+
+ `; + }).join(''); + + const html = ` + + `; + showModal(html); +} + +export function addClientMetric(clientId) { + showBodyMetricsDialog( + state.bodyMetrics.filter(m => m.client_id == clientId), + (newMetric) => { + if (!newMetric) return; + newMetric.id = genId(); + newMetric.client_id = parseInt(clientId); + state.bodyMetrics.unshift(newMetric); + state.bodyMetrics.sort((a, b) => new Date(b.date) - new Date(a.date)); + saveState(); + showClientMetrics(clientId); // Перерисовываем модалку + } + ); +} + +export function editClientMetric(metricId, clientId) { + 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(); + showClientMetrics(clientId); + }, + metric + ); +} + +export function deleteClientMetric(metricId, clientId) { + if (!confirm('Удалить замер?')) return; + state.bodyMetrics = state.bodyMetrics.filter(m => m.id !== metricId); + saveState(); + showClientMetrics(clientId); +} + +// Обновляем экспорт window.SettingsLogic = { show: showSettings, addRefExercise, deleteRefExercise, updateRefExerciseType, editRefExercise, clearAllData, _renderRefExercises, _renderBodyMetricsList, + // Клиенты: + showAddClientModal, submitClient, deleteClient, _renderClientsList, + selectClientColor, + // Замеры клиента: + showClientMetrics, addClientMetric, editClientMetric, deleteClientMetric, }; + +// Делаем state доступным для inline onclick +window.state = state; diff --git a/frontend/js/logic/workout.js b/frontend/js/logic/workout.js index 30457d5..6ef5a80 100644 --- a/frontend/js/logic/workout.js +++ b/frontend/js/logic/workout.js @@ -1,10 +1,23 @@ import { state, saveState, genId } from '../state.js'; import { renderMainScreen } from '../render/main-screen.js'; import { showEditWorkoutDateDialog } from '../modals/edit-workout-date-modal.js'; +import { showClientSelectModal } from '../modals/client-select-modal.js'; export function startWorkout() { + // В режиме тренера — сначала выбираем клиента + if (state.mode === 'trainer') { + showClientSelectModal((clientId) => { + _createWorkout(clientId); + }); + } else { + _createWorkout(null); + } +} + +function _createWorkout(clientId) { const w = { id: genId(), + client_id: clientId, startTime: new Date().toISOString(), endTime: null, type: '', diff --git a/frontend/js/modals/client-select-modal.js b/frontend/js/modals/client-select-modal.js new file mode 100644 index 0000000..0ca7f32 --- /dev/null +++ b/frontend/js/modals/client-select-modal.js @@ -0,0 +1,58 @@ +import { showModal, closeModal } from './modal-base.js'; +import { state, CLIENT_COLORS, DEFAULT_COLOR, genId, generateUUID } from '../state.js'; + +let _cb = null; + +export function showClientSelectModal(callback) { + _cb = callback; + + if (state.clients.length === 0) { + // Нет клиентов — предлагаем создать + showModal(` + + `); + return; + } + + const clientsHtml = state.clients.map(c => ` +
+
+
${c.full_name}
+
${c.goal || '—'}
+
+ › +
+ `).join(''); + + showModal(` + + `); +} + +export function select(clientId) { + closeModal(); + if (_cb) _cb(parseInt(clientId)); +} + +export function goToSettings() { + closeModal(); + window.SettingsLogic.show(); +} + +window.ClientSelectModal = { select, goToSettings }; diff --git a/frontend/js/modals/modal-base.js b/frontend/js/modals/modal-base.js index 00df844..976e315 100644 --- a/frontend/js/modals/modal-base.js +++ b/frontend/js/modals/modal-base.js @@ -30,4 +30,10 @@ export function stepValue(id, delta) { input.value = parseFloat((Math.round(newVal / step) * step).toFixed(4)); } -window.ModalBase = { closeOnBg, closeModal, close: closeModal, stepValue }; +window.ModalBase = { + showModal, + closeModal, + close: closeModal, + closeOnBg, + stepValue +}; diff --git a/frontend/js/render/main-screen.js b/frontend/js/render/main-screen.js index c7d8df7..1e79691 100644 --- a/frontend/js/render/main-screen.js +++ b/frontend/js/render/main-screen.js @@ -1,28 +1,44 @@ -// Рендеринг главного экрана: список тренировок. -// Получает данные из state, возвращает DOM. - -import { state } from '../state.js'; +import { state, DEFAULT_COLOR } from '../state.js'; import { renderWorkoutCard } from './workout-card.js'; -import { startWorkout } from '../logic/workout.js'; export function renderMainScreen() { const app = document.getElementById('app'); + if (!app) return; app.innerHTML = ''; - if (state.workouts.length === 0) { + // В режиме тренера — подсказка сверху + if (state.mode === 'trainer') { + const hint = document.createElement('div'); + hint.className = 'card mb-10'; + hint.style.background = 'rgba(187, 134, 252, 0.1)'; + hint.style.borderColor = 'var(--primary)'; + hint.innerHTML = ` +
+
+
👨‍🏫 Режим тренера
+
При создании тренировки выберите клиента
+
+ +
+ `; + app.appendChild(hint); + } + + // Все тренировки (в обоих режимах) + const workoutsToShow = state.workouts; + + if (workoutsToShow.length === 0) { app.innerHTML = `

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

-

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

- +

${state.mode === 'trainer' ? 'Создайте первую тренировку для клиента' : 'Начните первую тренировку'}

+
`; - document.getElementById('btn-start-first').onclick = startWorkout; return; } - // Сортировка: активные сверху, завершённые снизу - const sorted = [...state.workouts].sort((a, b) => { + const sorted = [...workoutsToShow].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); @@ -33,7 +49,7 @@ export function renderMainScreen() { const btn = document.createElement('button'); btn.className = 'btn mb-10'; btn.textContent = '▶ Начать новую тренировку'; - btn.onclick = startWorkout; + btn.onclick = () => window.WL.startWorkout(); app.appendChild(btn); } diff --git a/frontend/js/render/workout-card.js b/frontend/js/render/workout-card.js index ca8c54a..eee30a0 100644 --- a/frontend/js/render/workout-card.js +++ b/frontend/js/render/workout-card.js @@ -8,9 +8,25 @@ export function renderWorkoutCard(w) { const isExpanded = state.expandedWorkoutId === w.id; const isReadOnly = w.synced && w.endTime; + // Определяем цвет тренировки + let clientColor = null; + let clientName = null; + if (w.client_id) { + const client = state.clients.find(c => c.id == w.client_id); + if (client) { + clientColor = client.color; + clientName = client.full_name; + } + } + const div = document.createElement('div'); div.className = 'workout-accordion'; + // Добавляем цветной border-left, если есть клиент + if (clientColor) { + div.style.borderLeft = `4px solid ${clientColor}`; + } + const headerClass = `workout-header ${isActive ? 'active' : 'completed'}`; const dateStr = formatDate(w.startTime); const typeStr = w.type || 'тип не выбран'; @@ -28,11 +44,17 @@ export function renderWorkoutCard(w) { ` : ''; + // Метка клиента (если есть) + const clientLabel = clientName + ? `
👤 ${clientName}
` + : ''; + div.innerHTML = `
${isActive ? '🔥 Тренировка' : '✅ Завершена'}
${dateStr} ${editDateBtn} • ${typeStr}
+ ${clientLabel}
Упр: ${exCount} • Кардио: ${cardioCount}
${statusBadge} ›
diff --git a/frontend/js/state.js b/frontend/js/state.js index 07ba734..d868278 100644 --- a/frontend/js/state.js +++ b/frontend/js/state.js @@ -1,22 +1,42 @@ const STORAGE_KEY = 'gym_tracker_v6'; +// Палитра из 16 контрастных цветов для клиентов +export const CLIENT_COLORS = [ + '#bb86fc', // фиолетовый (дефолтный для личного режима) + '#03dac6', // бирюзовый + '#fbbc04', // жёлтый + '#cf6679', // розовый + '#64b5f6', // голубой + '#81c784', // зелёный + '#ffb74d', // оранжевый + '#ba68c8', // светло-фиолетовый + '#4dd0e1', // циан + '#e57373', // красный + '#aed581', // салатовый + '#ffd54f', // янтарный + '#9575cd', // пурпурный + '#4db6ac', // тиловый + '#f06292', // малиновый + '#7986cb', // индиго +]; + +export const DEFAULT_COLOR = CLIENT_COLORS[0]; + export const state = { workouts: [], exerciseRef: [], bodyMetrics: [], - // Справочники с бэкенда workoutTypes: [], metricTypes: [], - clients: [], + clients: [], // каждый клиент: {id, sync_id, full_name, birth_date, goal, color} expandedWorkoutId: null, expandedExerciseId: null, nextId: 1, - // Режимы: 'personal' (по умолчанию) или 'trainer' mode: 'personal', - currentClientId: null, // В личном режиме null, в режиме тренера - ID клиента + currentClientId: null, // больше не используется глобально, но оставим для совместимости }; export function loadState() { diff --git a/frontend/styles/components.css b/frontend/styles/components.css index 132cdf6..1a4c6b1 100644 --- a/frontend/styles/components.css +++ b/frontend/styles/components.css @@ -91,3 +91,42 @@ textarea { resize: vertical; min-height: 60px; } background: var(--success); color: #000; padding: 8px 12px; border-radius: 8px; font-size: 0.85rem; font-weight: 600; text-align: center; margin-bottom: 12px; } + +/* === Client select list (в модалке) === */ +.client-select-list { + max-height: 60vh; + overflow-y: auto; +} +.client-select-item { + display: flex; + align-items: center; + padding: 14px; + background: var(--surface-2); + border-radius: 8px; + margin-bottom: 8px; + cursor: pointer; + transition: background 0.15s; +} +.client-select-item:active { + background: var(--surface); +} + +/* === Color palette (в настройках клиента) === */ +.color-palette { + display: grid; + grid-template-columns: repeat(8, 1fr); + gap: 6px; + margin-top: 8px; +} +.color-swatch { + aspect-ratio: 1; + border-radius: 6px; + cursor: pointer; + border: 2px solid transparent; + transition: transform 0.1s; +} +.color-swatch:active { transform: scale(0.9); } +.color-swatch.selected { + border-color: var(--text); + box-shadow: 0 0 0 2px var(--bg), 0 0 0 4px var(--text); +} diff --git a/readme.md b/readme.md index 5a66555..3a96e26 100644 --- a/readme.md +++ b/readme.md @@ -19,4 +19,3 @@ clients (ФИО, ДР, цель) │ 1:N └──► body_metric_values ──► metric_types (справочник) ``` -