From 1f81632083b5fb612f24127083070855e89fb5ab Mon Sep 17 00:00:00 2001 From: "Wayne H. Shephard" Date: Wed, 30 Sep 2026 02:25:03 +0300 Subject: [PATCH] front-back integrated --- backend/gym.db | Bin 151552 -> 151552 bytes backend/main.py | 2 + backend/routers/workout_types.py | 31 +++++ frontend/js/api.js | 44 +++++++ frontend/js/app.js | 25 +++- frontend/js/logic/exercise.js | 22 +++- frontend/js/logic/settings.js | 31 ++++- frontend/js/logic/workout.js | 13 ++- frontend/js/modals/edit-workout-date-modal.js | 2 +- frontend/js/modals/modal-base.js | 2 +- frontend/js/render/workout-card.js | 17 ++- frontend/js/state.js | 28 ++++- frontend/js/sync.js | 108 +++++++++++++++++- 13 files changed, 298 insertions(+), 27 deletions(-) create mode 100644 backend/routers/workout_types.py create mode 100644 frontend/js/api.js diff --git a/backend/gym.db b/backend/gym.db index 2b62ce5905422994c68d6a3c47f3d492d6fa2bd4..0ecad92fb0de9e141df900382d1994b83b4a2e31 100644 GIT binary patch delta 242 zcmZozz}c{XbAmLZ;zSu|M#YT@Oa8Ml^0zbaw{L8$<*zSiCiNHjGz zN-|2-O-@WQ&^0kIFxRy-Hnh|=NJ=p?Oi4^NHaAW(GB7gJHL%n*vJ5dYwlc7^GBMFJ zGc<}q6*n}oGP5)^voyvj&c(pMz{t;XgMt6XW VKy#RxIXM>zBrI|eShj$10{}pdI$Qt% delta 34 qcmZozz}c{XbAmLZ!bBNoMum+DOa5 -

Gym Tracker

+

Gym Tracker (${modeLabel})

● Локально
+ @@ -33,17 +35,28 @@ function renderHeader() { `; } -function init() { +async function init() { loadState(); renderHeader(); renderMainScreen(); updateSyncStatus(); + + // Загружаем справочники с бэкенда в фоне + await initAppData(); } -// Глобальные хелперы для inline-обработчиков +// Глобальные хелперы window.App = { getWorkout: (id) => state.workouts.find(w => w.id === id), - saveAndRender: () => { saveState(); /* перерисовка не нужна — select не требует */ }, + 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' ? 'Тренер' : 'Личный трекер'}`); + } }; window.AppSync = { sync }; window.App.backToMain = () => renderMainScreen(); diff --git a/frontend/js/logic/exercise.js b/frontend/js/logic/exercise.js index c091d64..5bc1780 100644 --- a/frontend/js/logic/exercise.js +++ b/frontend/js/logic/exercise.js @@ -1,6 +1,7 @@ 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'; export function addExercise(workoutId) { const w = state.workouts.find(x => x.id === workoutId); @@ -45,7 +46,26 @@ export function deleteExercise(workoutId, exerciseId) { renderMainScreen(); } -export function getExerciseHistory(exerciseName) { +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); + } + + // Fallback на локальные данные const normalizedName = exerciseName.trim().toLowerCase(); const history = []; for (const w of state.workouts) { diff --git a/frontend/js/logic/settings.js b/frontend/js/logic/settings.js index 4c1a2aa..3561724 100644 --- a/frontend/js/logic/settings.js +++ b/frontend/js/logic/settings.js @@ -112,6 +112,12 @@ export function _renderRefExercises() { if (a.workoutType !== b.workoutType) return (a.workoutType || '').localeCompare(b.workoutType || ''); return a.name.localeCompare(b.name); }); + + // Генерируем опции для select динамически из state.workoutTypes + const typeOptions = state.workoutTypes.map(wt => + `` + ).join(''); + list.innerHTML = sorted.map(ex => { let count = 0; state.workouts.forEach(w => { @@ -130,12 +136,7 @@ export function _renderRefExercises() { @@ -145,6 +146,24 @@ export function _renderRefExercises() { }).join(''); } +// Добавим отображение типов замеров в настройки (для тренера) +export function _renderMetricTypes() { + const container = document.getElementById('metric-types-list'); + if (!container) return; + if (state.metricTypes.length === 0) { + container.innerHTML = '

Нет типов замеров

'; + return; + } + container.innerHTML = state.metricTypes.map(mt => ` +
+
+ ${mt.name} + ${mt.unit} +
+
+ `).join(''); +} + export function _renderBodyMetricsList() { const list = document.getElementById('body-metrics-list'); if (!list) return; diff --git a/frontend/js/logic/workout.js b/frontend/js/logic/workout.js index ab64723..30457d5 100644 --- a/frontend/js/logic/workout.js +++ b/frontend/js/logic/workout.js @@ -60,7 +60,16 @@ export function editWorkoutDate(workoutId) { }); } +export function cancelEmptyWorkout(workoutId) { + if (!confirm('Отменить эту тренировку? Все данные будут удалены.')) return; + deleteWorkout(workoutId); +} + window.WL = { - startWorkout, finishWorkout, deleteWorkout, - toggleWorkout, editWorkoutDate, + startWorkout, + finishWorkout, + deleteWorkout, + toggleWorkout, + editWorkoutDate, + cancelEmptyWorkout }; diff --git a/frontend/js/modals/edit-workout-date-modal.js b/frontend/js/modals/edit-workout-date-modal.js index 0f47fa6..8bf4e41 100644 --- a/frontend/js/modals/edit-workout-date-modal.js +++ b/frontend/js/modals/edit-workout-date-modal.js @@ -20,7 +20,7 @@ export function showEditWorkoutDateDialog(workout, callback) {
- +
diff --git a/frontend/js/modals/modal-base.js b/frontend/js/modals/modal-base.js index c5a07e3..00df844 100644 --- a/frontend/js/modals/modal-base.js +++ b/frontend/js/modals/modal-base.js @@ -30,4 +30,4 @@ export function stepValue(id, delta) { input.value = parseFloat((Math.round(newVal / step) * step).toFixed(4)); } -window.ModalBase = { closeOnBg, closeModal, stepValue }; +window.ModalBase = { closeOnBg, closeModal, close: closeModal, stepValue }; diff --git a/frontend/js/render/workout-card.js b/frontend/js/render/workout-card.js index 57b56d9..ca8c54a 100644 --- a/frontend/js/render/workout-card.js +++ b/frontend/js/render/workout-card.js @@ -148,15 +148,30 @@ export function renderWorkoutDetails(w) { // Блок завершения const finishBlock = document.createElement('div'); finishBlock.className = 'finish-block'; + if (isReadOnly) { finishBlock.innerHTML = `

🔒 Архив

Тренировка синхронизирована и завершена. Редактирование недоступно.

`; } else if (isActive) { + // Проверяем, есть ли уже данные в тренировке + const hasData = (w.exercises && w.exercises.length > 0) || (w.cardio && w.cardio.length > 0); + + // Кнопка отмены показывается ТОЛЬКО если тренировка пустая + const cancelBtnHtml = !hasData ? ` + + ` : ''; + finishBlock.innerHTML = `

Завершение тренировки

После завершения данные останутся локально до синхронизации

- `; +
+ ${cancelBtnHtml} + +
+ `; } else { finishBlock.innerHTML = `

Тренировка завершена

diff --git a/frontend/js/state.js b/frontend/js/state.js index cef23bb..07ba734 100644 --- a/frontend/js/state.js +++ b/frontend/js/state.js @@ -1,12 +1,22 @@ -const STORAGE_KEY = 'gym_tracker_v5'; +const STORAGE_KEY = 'gym_tracker_v6'; export const state = { workouts: [], exerciseRef: [], bodyMetrics: [], + + // Справочники с бэкенда + workoutTypes: [], + metricTypes: [], + clients: [], + expandedWorkoutId: null, expandedExerciseId: null, nextId: 1, + + // Режимы: 'personal' (по умолчанию) или 'trainer' + mode: 'personal', + currentClientId: null, // В личном режиме null, в режиме тренера - ID клиента }; export function loadState() { @@ -14,9 +24,12 @@ export function loadState() { if (saved) { try { const parsed = JSON.parse(saved); + // Объединяем, чтобы не потерять новые поля при обновлении версии Object.assign(state, parsed); - if (!state.exerciseRef) state.exerciseRef = []; - if (!state.bodyMetrics) state.bodyMetrics = []; + if (!state.workoutTypes) state.workoutTypes = []; + if (!state.metricTypes) state.metricTypes = []; + if (!state.clients) state.clients = []; + if (!state.mode) state.mode = 'personal'; } catch (e) { console.error('Failed to load state', e); } @@ -32,6 +45,15 @@ export function genId() { return (state.nextId++).toString(); } +// Генерация UUID v4 для sync_id (идемпотентность на бэкенде) +export function generateUUID() { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => { + const r = Math.random() * 16 | 0; + const v = c === 'x' ? r : (r & 0x3 | 0x8); + return v.toString(16); + }); +} + export function updateSyncStatus() { const dirty = state.workouts.filter(w => !w.synced); const el = document.getElementById('sync-status'); diff --git a/frontend/js/sync.js b/frontend/js/sync.js index f0e2799..31c7eb7 100644 --- a/frontend/js/sync.js +++ b/frontend/js/sync.js @@ -1,4 +1,20 @@ -import { state, saveState } from './state.js'; +import { state, saveState, generateUUID } from './state.js'; +import { fetchWorkoutTypes, fetchMetricTypes, fetchClients, syncData } from './api.js'; +import { renderMainScreen } from './render/main-screen.js'; + +export async function initAppData() { + try { + // Загружаем справочники с бэкенда + state.workoutTypes = await fetchWorkoutTypes(); + state.metricTypes = await fetchMetricTypes(); + state.clients = await fetchClients(); + saveState(); + console.log('Справочники загружены с бэкенда'); + } catch (e) { + console.warn('Не удалось загрузить справочники (возможно, бэк недоступен):', e); + // Не ломаем приложение, работаем офлайн с тем, что есть + } +} export function getDirtyWorkouts() { return state.workouts.filter(w => !w.synced); @@ -10,9 +26,89 @@ export async function sync() { alert('Нет данных для синхронизации'); return; } - // TODO: заменить на fetch('/api/sync', ...) - console.log('Syncing:', JSON.stringify(dirty, null, 2)); - dirty.forEach(w => w.synced = true); - saveState(); - alert(`Отправлено тренировок: ${dirty.length}`); + + const btn = document.querySelector('.header-actions .btn-secondary'); + if (btn) { + btn.disabled = true; + btn.textContent = '⏳ Отправка...'; + } + + try { + // Маппинг данных из формата фронтенда в формат бэкенда + const payload = { + workouts: dirty.map(w => { + // Находим ID типа тренировки по его имени + const wt = state.workoutTypes.find(t => t.name === w.type); + + return { + sync_id: w.sync_id || generateUUID(), + client_id: state.currentClientId || null, + workout_type_id: wt ? wt.id : null, + start_time: w.startTime, + end_time: w.endTime, + notes: w.notes || "", + workout_exercises: (w.exercises || []).map((ex, exIdx) => ({ + sync_id: ex.sync_id || generateUUID(), + exercise_id: ex.exercise_id || null, + exercise_name: ex.name, + sort_order: exIdx, + sets: (ex.sets || []).map((s, sIdx) => ({ + sync_id: s.sync_id || generateUUID(), + is_dropset: s.isDropset ? 1 : 0, + weight: s.isDropset ? null : (parseFloat(s.weight) || null), + reps: s.isDropset ? null : (parseInt(s.reps) || null), + rating: s.rating || 3, + notes: s.notes || "", + sort_order: sIdx, + timestamp: s.timestamp || new Date().toISOString(), + dropset_items: s.isDropset ? (s.subSets || []).map((item, idx) => ({ + weight: parseFloat(item.weight) || 0, + reps: parseInt(item.reps) || 0, + sort_order: idx + })) : [] + })) + })), + cardio_sessions: (w.cardio || []).map((c, cIdx) => ({ + sync_id: c.sync_id || generateUUID(), + cardio_type: c.type, + purpose: c.purpose, + duration: c.duration || null, + distance: c.distance || null, + speed: c.speed || null, + incline: c.incline || null, + resistance: c.resistance || null, + notes: c.notes || "", + sort_order: cIdx + })) + }; + }) + }; + + console.log('📤 Отправляем на синхронизацию:', payload); + const response = await syncData(payload); + console.log('✅ Ответ синхронизации:', response); + + // Помечаем локально как синхронизированные + dirty.forEach(w => w.synced = true); + saveState(); + + // Перерисовываем интерфейс (чтобы обновить бейджи и read-only режим) + if (typeof window.renderMainScreen === 'function') { + window.renderMainScreen(); + } else { + // Fallback если функция не экспортирована в window + location.reload(); + } + + alert(`Успешно синхронизировано: ${response.created.workouts || 0} тренировок`); + + } catch (e) { + console.error('❌ Sync error:', e); + alert('Ошибка синхронизации: ' + e.message); + } finally { + if (btn) { + btn.disabled = false; + btn.textContent = '🔄'; + } + } }