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})
● Локально
-
+
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 = '🔄';
+ }
+ }
}