integrated front and back

This commit is contained in:
2026-10-01 23:52:12 +03:00
parent 1f81632083
commit 3e966c9ff6
15 changed files with 634 additions and 72 deletions
BIN
View File
Binary file not shown.
+1 -1
View File
@@ -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"
)
+8 -7
View File
@@ -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 = `
<div>
<h2>Gym Tracker <span style="font-size:0.8rem; color:var(--text-secondary); font-weight:400;">(${modeLabel})</span></h2>
<h2>Gym Bro <span style="font-size:0.8rem; color:var(--text-secondary); font-weight:400;">(${modeLabel})</span></h2>
<div id="sync-status" class="sync-status">● Локально</div>
</div>
<div class="header-actions">
<button class="icon-btn header-btn" onclick="App.toggleMode()" title="Сменить режим">🔄</button>
<button class="icon-btn header-btn" onclick="ML.showBodyMetrics()" title="Замеры">📏</button>
<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>
@@ -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);
+74 -30
View File
@@ -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);
const history = [];
for (const w of state.workouts) {
// Не показываем текущую тренировку в истории
if (excludeWorkoutId && String(w.id) === String(excludeWorkoutId)) {
continue;
}
// 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 (!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,
};
+8 -4
View File
@@ -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();
}
+49 -3
View File
@@ -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,
};
+304 -10
View File
@@ -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() {
<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>
<button class="btn btn-small mb-10" onclick="SettingsLogic.showAddClientModal()">+ Добавить клиента</button>
<div id="clients-list"></div>
</div>
<div class="settings-section">
<h3>📋 Справочник упражнений</h3>
<p class="text-muted text-small mb-10">Управляйте списком упражнений и привязывайте их к типам тренировок.</p>
@@ -20,30 +30,67 @@ export function showSettings() {
<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>
${state.workoutTypes.map(wt => `<option value="${wt.name}">${wt.name}</option>`).join('')}
</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>
<h3>📏 Личные замеры</h3>
<p class="text-muted text-small mb-10">Замеры без привязки к клиенту (для личного режима).</p>
<button class="btn btn-small mb-10" onclick="ML.showBodyMetrics()">+ Добавить личный замер</button>
<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);
_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 = '<p class="text-muted text-small">Нет личных замеров</p>';
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 `
<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('');
}
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 => `
<div class="color-swatch ${client.color === color ? 'selected' : ''}"
style="background:${color};"
onclick="SettingsLogic.selectClientColor('${color}')"></div>
`).join('');
const html = `
<div class="modal-content">
<h3 class="modal-title">${isEdit ? '✎ Редактировать' : '👤 Новый'} клиент</h3>
<div class="field-group">
<label>ФИО</label>
<input type="text" id="m-client-name" value="${client.full_name}" placeholder="Иванов Иван Иванович">
</div>
<div class="field-group">
<label>Дата рождения</label>
<input type="date" id="m-client-dob" value="${client.birth_date || ''}">
</div>
<div class="field-group">
<label>Цель тренировок</label>
<input type="text" id="m-client-goal" value="${client.goal || ''}" placeholder="Набор массы, похудение...">
</div>
<div class="field-group">
<label>Цвет клиента</label>
<div class="color-palette" id="m-client-palette">
${paletteHtml}
</div>
</div>
<div class="btn-row">
<button class="btn btn-secondary" onclick="ModalBase.closeModal()">Отмена</button>
<button class="btn btn-success" onclick="SettingsLogic.submitClient('${editingClient?.id || ''}')">Сохранить</button>
</div>
</div>
`;
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 = '<p class="text-muted text-small">Нет клиентов. Добавьте первого!</p>';
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 `
<div class="exercise-ref-item" style="border-left: 4px solid ${c.color || DEFAULT_COLOR}; padding-left: 10px;">
<div style="flex:1;">
<div style="font-weight:600;">${c.full_name}</div>
<div class="text-small text-muted">${c.goal || 'Цель не указана'}</div>
<div class="text-small text-muted" style="margin-top:4px;">
🏋️ ${clientWorkouts.length} трен. • 📏 ${clientMetrics.length} замеров
</div>
${lastMetric ? `<div class="text-small" style="margin-top:2px; color:var(--text-secondary);">Последний: ${metricsPreview}</div>` : ''}
</div>
<div style="display:flex; gap:4px; flex-direction:column;">
<button class="icon-btn" onclick="SettingsLogic.showClientMetrics('${c.id}')" title="Замеры">📏</button>
<button class="icon-btn" onclick="SettingsLogic.showAddClientModal(state.clients.find(x => x.id == '${c.id}'))" title="Редактировать">✎</button>
<button class="icon-btn danger" onclick="SettingsLogic.deleteClient('${c.id}')" title="Удалить">✕</button>
</div>
</div>
`;
}).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
? '<p class="text-muted text-small">Нет замеров</p>'
: 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 `
<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="SettingsLogic.editClientMetric('${m.id}', '${clientId}')">✎</button>
<button class="icon-btn danger" onclick="SettingsLogic.deleteClientMetric('${m.id}', '${clientId}')">✕</button>
</div>
</div>
`;
}).join('');
const html = `
<div class="modal-content">
<h3 class="modal-title">📏 Замеры: ${client.full_name}</h3>
<div id="client-metrics-list" style="max-height:50vh; overflow-y:auto;">
${metricsHtml}
</div>
<div class="btn-row" style="margin-top:16px;">
<button class="btn btn-secondary" onclick="ModalBase.closeModal()">Закрыть</button>
<button class="btn" onclick="SettingsLogic.addClientMetric('${clientId}')">+ Добавить замер</button>
</div>
</div>
`;
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;
+13
View File
@@ -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: '',
+58
View File
@@ -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(`
<div class="modal-content">
<h3 class="modal-title">👤 Нет клиентов</h3>
<p class="text-muted">Сначала добавьте клиента в настройках.</p>
<div class="btn-row">
<button class="btn btn-secondary" onclick="ModalBase.closeModal()">Отмена</button>
<button class="btn" onclick="ClientSelectModal.goToSettings()">⚙️ В настройки</button>
</div>
</div>
`);
return;
}
const clientsHtml = state.clients.map(c => `
<div class="client-select-item" onclick="ClientSelectModal.select('${c.id}')"
style="border-left: 4px solid ${c.color || DEFAULT_COLOR};">
<div style="flex:1;">
<div style="font-weight:600;">${c.full_name}</div>
<div class="text-small text-muted">${c.goal || '—'}</div>
</div>
<span style="color:var(--text-secondary);">›</span>
</div>
`).join('');
showModal(`
<div class="modal-content">
<h3 class="modal-title">👤 Для кого тренировка?</h3>
<div class="client-select-list">
${clientsHtml}
</div>
<div class="btn-row" style="margin-top:16px;">
<button class="btn btn-secondary" onclick="ModalBase.closeModal()">Отмена</button>
</div>
</div>
`);
}
export function select(clientId) {
closeModal();
if (_cb) _cb(parseInt(clientId));
}
export function goToSettings() {
closeModal();
window.SettingsLogic.show();
}
window.ClientSelectModal = { select, goToSettings };
+7 -1
View File
@@ -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
};
+28 -12
View File
@@ -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 = `
<div style="display:flex; justify-content:space-between; align-items:center;">
<div>
<div style="font-weight:600;">👨‍🏫 Режим тренера</div>
<div class="text-small text-muted">При создании тренировки выберите клиента</div>
</div>
<button class="btn btn-small" onclick="WL.startWorkout()">▶ Новая тренировка</button>
</div>
`;
app.appendChild(hint);
}
// Все тренировки (в обоих режимах)
const workoutsToShow = state.workouts;
if (workoutsToShow.length === 0) {
app.innerHTML = `
<div class="empty-state">
<h3>🏋️ Нет тренировок</h3>
<p>Начните первую тренировку</p>
<button class="btn" id="btn-start-first">▶ Начать тренировку</button>
<p>${state.mode === 'trainer' ? 'Создайте первую тренировку для клиента' : 'Начните первую тренировку'}</p>
<button class="btn" onclick="WL.startWorkout()">▶ Начать тренировку</button>
</div>
`;
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);
}
+22
View File
@@ -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) {
</span>`
: '';
// Метка клиента (если есть)
const clientLabel = clientName
? `<div class="workout-meta" style="color:${clientColor}; font-weight:600;">👤 ${clientName}</div>`
: '';
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>
${clientLabel}
<div class="workout-meta">Упр: ${exCount} • Кардио: ${cardioCount}</div>
</div>
<div>${statusBadge} <span class="chevron ${isExpanded ? 'open' : ''}">›</span></div>
+24 -4
View File
@@ -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() {
+39
View File
@@ -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);
}
-1
View File
@@ -19,4 +19,3 @@ clients (ФИО, ДР, цель)
│ 1:N
└──► body_metric_values ──► metric_types (справочник)
```