diff --git a/backend/gym.db b/backend/gym.db index 2b62ce5..0ecad92 100644 Binary files a/backend/gym.db and b/backend/gym.db differ diff --git a/backend/main.py b/backend/main.py index 612f3d8..4eaa16a 100644 --- a/backend/main.py +++ b/backend/main.py @@ -4,6 +4,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from database import engine, Base import routers.clients +import routers.workout_types import routers.workouts import routers.exercises import routers.metrics @@ -29,6 +30,7 @@ app.add_middleware( # Подключаем роутеры app.include_router(routers.clients.router) +app.include_router(routers.workout_types.router) app.include_router(routers.workouts.router) app.include_router(routers.exercises.router) app.include_router(routers.metrics.router) diff --git a/backend/routers/workout_types.py b/backend/routers/workout_types.py new file mode 100644 index 0000000..693c25f --- /dev/null +++ b/backend/routers/workout_types.py @@ -0,0 +1,31 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from typing import List +import database +import models +import schemas +from datetime import datetime + +router = APIRouter(prefix="/workout-types", tags=["workout-types"]) + +def get_current_timestamp(): + return datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%fZ') + +@router.post("/", response_model=schemas.WorkoutType) +def create_workout_type(wt: schemas.WorkoutTypeCreate, db: Session = Depends(database.get_db)): + db_wt = db.query(models.WorkoutType).filter(models.WorkoutType.sync_id == wt.sync_id).first() + if db_wt: + raise HTTPException(status_code=400, detail="WorkoutType with this sync_id already exists") + + db_wt = models.WorkoutType( + **wt.dict(), + created_at=get_current_timestamp() + ) + db.add(db_wt) + db.commit() + db.refresh(db_wt) + return db_wt + +@router.get("/", response_model=List[schemas.WorkoutType]) +def list_workout_types(db: Session = Depends(database.get_db)): + return db.query(models.WorkoutType).order_by(models.WorkoutType.sort_order).all() diff --git a/frontend/js/api.js b/frontend/js/api.js new file mode 100644 index 0000000..3dc9e69 --- /dev/null +++ b/frontend/js/api.js @@ -0,0 +1,44 @@ +// Базовый URL API. Если фронт и бэк на одном домене, можно использовать '' или '/api' +const API_BASE = 'http://localhost:8000'; + +export async function fetchWorkoutTypes() { + const res = await fetch(`${API_BASE}/workout-types/`); + if (!res.ok) throw new Error('Failed to fetch workout types'); + return await res.json(); +} + +export async function fetchMetricTypes() { + const res = await fetch(`${API_BASE}/metric-types/`); + if (!res.ok) throw new Error('Failed to fetch metric types'); + return await res.json(); +} + +export async function fetchClients() { + const res = await fetch(`${API_BASE}/clients/`); + if (!res.ok) throw new Error('Failed to fetch clients'); + return await res.json(); +} + +export async function fetchExerciseHistory(exerciseName, clientId = null) { + let url = `${API_BASE}/exercises/history/${encodeURIComponent(exerciseName)}?limit=10`; + if (clientId) url += `&client_id=${clientId}`; + + const res = await fetch(url); + if (!res.ok) throw new Error('Failed to fetch history'); + return await res.json(); +} + +export async function syncData(payload) { + const res = await fetch(`${API_BASE}/sync/`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + + if (!res.ok) { + const err = await res.json(); + console.error('❌ Sync API Error Detail:', err); // <-- Покажет, какое именно поле не прошло валидацию + throw new Error(err.detail ? JSON.stringify(err.detail) : 'Sync failed'); + } + return await res.json(); +} diff --git a/frontend/js/app.js b/frontend/js/app.js index 606c4e6..77bca33 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -1,8 +1,8 @@ import { loadState, saveState, state, updateSyncStatus } from './state.js'; import { renderMainScreen } from './render/main-screen.js'; -import { sync } from './sync.js'; +import { sync, initAppData } from './sync.js'; -// Подключаем logic-модули (они регистрируются в window) +// Подключаем logic-модули import './logic/workout.js'; import './logic/exercise.js'; import './logic/set.js'; @@ -20,12 +20,14 @@ import './modals/edit-exercise-modal.js'; import './modals/edit-workout-date-modal.js'; function renderHeader() { + const modeLabel = state.mode === 'trainer' ? '👨‍🏫 Тренер' : '👤 Личный'; document.getElementById('app-header').innerHTML = `
-

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 = '🔄'; + } + } }