front-back integrated
This commit is contained in:
Binary file not shown.
@@ -4,6 +4,7 @@ from fastapi import FastAPI
|
|||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from database import engine, Base
|
from database import engine, Base
|
||||||
import routers.clients
|
import routers.clients
|
||||||
|
import routers.workout_types
|
||||||
import routers.workouts
|
import routers.workouts
|
||||||
import routers.exercises
|
import routers.exercises
|
||||||
import routers.metrics
|
import routers.metrics
|
||||||
@@ -29,6 +30,7 @@ app.add_middleware(
|
|||||||
|
|
||||||
# Подключаем роутеры
|
# Подключаем роутеры
|
||||||
app.include_router(routers.clients.router)
|
app.include_router(routers.clients.router)
|
||||||
|
app.include_router(routers.workout_types.router)
|
||||||
app.include_router(routers.workouts.router)
|
app.include_router(routers.workouts.router)
|
||||||
app.include_router(routers.exercises.router)
|
app.include_router(routers.exercises.router)
|
||||||
app.include_router(routers.metrics.router)
|
app.include_router(routers.metrics.router)
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
+19
-6
@@ -1,8 +1,8 @@
|
|||||||
import { loadState, saveState, state, updateSyncStatus } from './state.js';
|
import { loadState, saveState, state, updateSyncStatus } from './state.js';
|
||||||
import { renderMainScreen } from './render/main-screen.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/workout.js';
|
||||||
import './logic/exercise.js';
|
import './logic/exercise.js';
|
||||||
import './logic/set.js';
|
import './logic/set.js';
|
||||||
@@ -20,12 +20,14 @@ import './modals/edit-exercise-modal.js';
|
|||||||
import './modals/edit-workout-date-modal.js';
|
import './modals/edit-workout-date-modal.js';
|
||||||
|
|
||||||
function renderHeader() {
|
function renderHeader() {
|
||||||
|
const modeLabel = state.mode === 'trainer' ? '👨🏫 Тренер' : '👤 Личный';
|
||||||
document.getElementById('app-header').innerHTML = `
|
document.getElementById('app-header').innerHTML = `
|
||||||
<div>
|
<div>
|
||||||
<h2>Gym Tracker</h2>
|
<h2>Gym Tracker <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 id="sync-status" class="sync-status">● Локально</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
<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="icon-btn header-btn" onclick="SettingsLogic.show()" title="Настройки">⚙️</button>
|
||||||
<button class="btn btn-small btn-secondary" onclick="AppSync.sync()" title="Синхронизация">🔄</button>
|
<button class="btn btn-small btn-secondary" onclick="AppSync.sync()" title="Синхронизация">🔄</button>
|
||||||
@@ -33,17 +35,28 @@ function renderHeader() {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function init() {
|
async function init() {
|
||||||
loadState();
|
loadState();
|
||||||
renderHeader();
|
renderHeader();
|
||||||
renderMainScreen();
|
renderMainScreen();
|
||||||
updateSyncStatus();
|
updateSyncStatus();
|
||||||
|
|
||||||
|
// Загружаем справочники с бэкенда в фоне
|
||||||
|
await initAppData();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Глобальные хелперы для inline-обработчиков
|
// Глобальные хелперы
|
||||||
window.App = {
|
window.App = {
|
||||||
getWorkout: (id) => state.workouts.find(w => w.id === id),
|
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.AppSync = { sync };
|
||||||
window.App.backToMain = () => renderMainScreen();
|
window.App.backToMain = () => renderMainScreen();
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { state, saveState, genId } from '../state.js';
|
import { state, saveState, genId } from '../state.js';
|
||||||
import { renderMainScreen } from '../render/main-screen.js';
|
import { renderMainScreen } from '../render/main-screen.js';
|
||||||
import { showExerciseDialog } from '../modals/exercise-modal.js';
|
import { showExerciseDialog } from '../modals/exercise-modal.js';
|
||||||
|
import { fetchExerciseHistory } from '../api.js';
|
||||||
|
|
||||||
export function addExercise(workoutId) {
|
export function addExercise(workoutId) {
|
||||||
const w = state.workouts.find(x => x.id === workoutId);
|
const w = state.workouts.find(x => x.id === workoutId);
|
||||||
@@ -45,7 +46,26 @@ export function deleteExercise(workoutId, exerciseId) {
|
|||||||
renderMainScreen();
|
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 normalizedName = exerciseName.trim().toLowerCase();
|
||||||
const history = [];
|
const history = [];
|
||||||
for (const w of state.workouts) {
|
for (const w of state.workouts) {
|
||||||
|
|||||||
@@ -112,6 +112,12 @@ export function _renderRefExercises() {
|
|||||||
if (a.workoutType !== b.workoutType) return (a.workoutType || '').localeCompare(b.workoutType || '');
|
if (a.workoutType !== b.workoutType) return (a.workoutType || '').localeCompare(b.workoutType || '');
|
||||||
return a.name.localeCompare(b.name);
|
return a.name.localeCompare(b.name);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Генерируем опции для select динамически из state.workoutTypes
|
||||||
|
const typeOptions = state.workoutTypes.map(wt =>
|
||||||
|
`<option value="${wt.name}" ${a => a.workoutType === wt.name ? 'selected' : ''}>${wt.name}</option>`
|
||||||
|
).join('');
|
||||||
|
|
||||||
list.innerHTML = sorted.map(ex => {
|
list.innerHTML = sorted.map(ex => {
|
||||||
let count = 0;
|
let count = 0;
|
||||||
state.workouts.forEach(w => {
|
state.workouts.forEach(w => {
|
||||||
@@ -130,12 +136,7 @@ export function _renderRefExercises() {
|
|||||||
<select onchange="SettingsLogic.updateRefExerciseType('${ex.id}', this.value)"
|
<select onchange="SettingsLogic.updateRefExerciseType('${ex.id}', this.value)"
|
||||||
style="width:auto; min-width:100px; padding:6px; font-size:0.8rem;">
|
style="width:auto; min-width:100px; padding:6px; font-size:0.8rem;">
|
||||||
<option value="">тип...</option>
|
<option value="">тип...</option>
|
||||||
<option value="грудь-руки" ${ex.workoutType==='грудь-руки'?'selected':''}>грудь-руки</option>
|
${state.workoutTypes.map(wt => `<option value="${wt.name}" ${ex.workoutType === wt.name ? 'selected' : ''}>${wt.name}</option>`).join('')}
|
||||||
<option value="ноги" ${ex.workoutType==='ноги'?'selected':''}>ноги</option>
|
|
||||||
<option value="спина-плечи" ${ex.workoutType==='спина-плечи'?'selected':''}>спина-плечи</option>
|
|
||||||
<option value="круговая" ${ex.workoutType==='круговая'?'selected':''}>круговая</option>
|
|
||||||
<option value="силовая" ${ex.workoutType==='силовая'?'selected':''}>силовая</option>
|
|
||||||
<option value="многоповторная" ${ex.workoutType==='многоповторная'?'selected':''}>многоповторная</option>
|
|
||||||
</select>
|
</select>
|
||||||
<button class="icon-btn" onclick="SettingsLogic.editRefExercise('${ex.id}')">✎</button>
|
<button class="icon-btn" onclick="SettingsLogic.editRefExercise('${ex.id}')">✎</button>
|
||||||
<button class="icon-btn danger" onclick="SettingsLogic.deleteRefExercise('${ex.id}')">✕</button>
|
<button class="icon-btn danger" onclick="SettingsLogic.deleteRefExercise('${ex.id}')">✕</button>
|
||||||
@@ -145,6 +146,24 @@ export function _renderRefExercises() {
|
|||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Добавим отображение типов замеров в настройки (для тренера)
|
||||||
|
export function _renderMetricTypes() {
|
||||||
|
const container = document.getElementById('metric-types-list');
|
||||||
|
if (!container) return;
|
||||||
|
if (state.metricTypes.length === 0) {
|
||||||
|
container.innerHTML = '<p class="text-muted text-small">Нет типов замеров</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
container.innerHTML = state.metricTypes.map(mt => `
|
||||||
|
<div class="exercise-ref-item">
|
||||||
|
<div>
|
||||||
|
<span class="name">${mt.name}</span>
|
||||||
|
<span class="type-badge">${mt.unit}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
export function _renderBodyMetricsList() {
|
export function _renderBodyMetricsList() {
|
||||||
const list = document.getElementById('body-metrics-list');
|
const list = document.getElementById('body-metrics-list');
|
||||||
if (!list) return;
|
if (!list) return;
|
||||||
|
|||||||
@@ -60,7 +60,16 @@ export function editWorkoutDate(workoutId) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function cancelEmptyWorkout(workoutId) {
|
||||||
|
if (!confirm('Отменить эту тренировку? Все данные будут удалены.')) return;
|
||||||
|
deleteWorkout(workoutId);
|
||||||
|
}
|
||||||
|
|
||||||
window.WL = {
|
window.WL = {
|
||||||
startWorkout, finishWorkout, deleteWorkout,
|
startWorkout,
|
||||||
toggleWorkout, editWorkoutDate,
|
finishWorkout,
|
||||||
|
deleteWorkout,
|
||||||
|
toggleWorkout,
|
||||||
|
editWorkoutDate,
|
||||||
|
cancelEmptyWorkout
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export function showEditWorkoutDateDialog(workout, callback) {
|
|||||||
<input type="datetime-local" id="m-w-end" value="${endValue}">
|
<input type="datetime-local" id="m-w-end" value="${endValue}">
|
||||||
</div>
|
</div>
|
||||||
<div class="btn-row">
|
<div class="btn-row">
|
||||||
<button class="btn btn-secondary" onclick="ModalBase.close()">Отмена</button>
|
<button class="btn btn-secondary" onclick="ModalBase.closeModal()">Отмена</button>
|
||||||
<button class="btn btn-success" onclick="EditDateModal.submit()">Сохранить</button>
|
<button class="btn btn-success" onclick="EditDateModal.submit()">Сохранить</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -30,4 +30,4 @@ export function stepValue(id, delta) {
|
|||||||
input.value = parseFloat((Math.round(newVal / step) * step).toFixed(4));
|
input.value = parseFloat((Math.round(newVal / step) * step).toFixed(4));
|
||||||
}
|
}
|
||||||
|
|
||||||
window.ModalBase = { closeOnBg, closeModal, stepValue };
|
window.ModalBase = { closeOnBg, closeModal, close: closeModal, stepValue };
|
||||||
|
|||||||
@@ -148,15 +148,30 @@ export function renderWorkoutDetails(w) {
|
|||||||
// Блок завершения
|
// Блок завершения
|
||||||
const finishBlock = document.createElement('div');
|
const finishBlock = document.createElement('div');
|
||||||
finishBlock.className = 'finish-block';
|
finishBlock.className = 'finish-block';
|
||||||
|
|
||||||
if (isReadOnly) {
|
if (isReadOnly) {
|
||||||
finishBlock.innerHTML = `
|
finishBlock.innerHTML = `
|
||||||
<h3>🔒 Архив</h3>
|
<h3>🔒 Архив</h3>
|
||||||
<p class="text-muted text-small">Тренировка синхронизирована и завершена. Редактирование недоступно.</p>`;
|
<p class="text-muted text-small">Тренировка синхронизирована и завершена. Редактирование недоступно.</p>`;
|
||||||
} else if (isActive) {
|
} else if (isActive) {
|
||||||
|
// Проверяем, есть ли уже данные в тренировке
|
||||||
|
const hasData = (w.exercises && w.exercises.length > 0) || (w.cardio && w.cardio.length > 0);
|
||||||
|
|
||||||
|
// Кнопка отмены показывается ТОЛЬКО если тренировка пустая
|
||||||
|
const cancelBtnHtml = !hasData ? `
|
||||||
|
<button class="btn btn-ghost mt-10" style="color: var(--danger); border-color: var(--danger);" onclick="WL.cancelEmptyWorkout('${w.id}')">
|
||||||
|
✕ Отменить пустую тренировку
|
||||||
|
</button>
|
||||||
|
` : '';
|
||||||
|
|
||||||
finishBlock.innerHTML = `
|
finishBlock.innerHTML = `
|
||||||
<h3>Завершение тренировки</h3>
|
<h3>Завершение тренировки</h3>
|
||||||
<p class="text-muted text-small">После завершения данные останутся локально до синхронизации</p>
|
<p class="text-muted text-small">После завершения данные останутся локально до синхронизации</p>
|
||||||
<button class="btn btn-danger" onclick="WL.finishWorkout('${w.id}')">⏹ Закончить тренировку</button>`;
|
<div style="display: flex; flex-direction: column; gap: 10px; margin-top: 10px;">
|
||||||
|
${cancelBtnHtml}
|
||||||
|
<button class="btn btn-danger" onclick="WL.finishWorkout('${w.id}')">⏹ Закончить тренировку</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
} else {
|
} else {
|
||||||
finishBlock.innerHTML = `
|
finishBlock.innerHTML = `
|
||||||
<h3>Тренировка завершена</h3>
|
<h3>Тренировка завершена</h3>
|
||||||
|
|||||||
+25
-3
@@ -1,12 +1,22 @@
|
|||||||
const STORAGE_KEY = 'gym_tracker_v5';
|
const STORAGE_KEY = 'gym_tracker_v6';
|
||||||
|
|
||||||
export const state = {
|
export const state = {
|
||||||
workouts: [],
|
workouts: [],
|
||||||
exerciseRef: [],
|
exerciseRef: [],
|
||||||
bodyMetrics: [],
|
bodyMetrics: [],
|
||||||
|
|
||||||
|
// Справочники с бэкенда
|
||||||
|
workoutTypes: [],
|
||||||
|
metricTypes: [],
|
||||||
|
clients: [],
|
||||||
|
|
||||||
expandedWorkoutId: null,
|
expandedWorkoutId: null,
|
||||||
expandedExerciseId: null,
|
expandedExerciseId: null,
|
||||||
nextId: 1,
|
nextId: 1,
|
||||||
|
|
||||||
|
// Режимы: 'personal' (по умолчанию) или 'trainer'
|
||||||
|
mode: 'personal',
|
||||||
|
currentClientId: null, // В личном режиме null, в режиме тренера - ID клиента
|
||||||
};
|
};
|
||||||
|
|
||||||
export function loadState() {
|
export function loadState() {
|
||||||
@@ -14,9 +24,12 @@ export function loadState() {
|
|||||||
if (saved) {
|
if (saved) {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(saved);
|
const parsed = JSON.parse(saved);
|
||||||
|
// Объединяем, чтобы не потерять новые поля при обновлении версии
|
||||||
Object.assign(state, parsed);
|
Object.assign(state, parsed);
|
||||||
if (!state.exerciseRef) state.exerciseRef = [];
|
if (!state.workoutTypes) state.workoutTypes = [];
|
||||||
if (!state.bodyMetrics) state.bodyMetrics = [];
|
if (!state.metricTypes) state.metricTypes = [];
|
||||||
|
if (!state.clients) state.clients = [];
|
||||||
|
if (!state.mode) state.mode = 'personal';
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to load state', e);
|
console.error('Failed to load state', e);
|
||||||
}
|
}
|
||||||
@@ -32,6 +45,15 @@ export function genId() {
|
|||||||
return (state.nextId++).toString();
|
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() {
|
export function updateSyncStatus() {
|
||||||
const dirty = state.workouts.filter(w => !w.synced);
|
const dirty = state.workouts.filter(w => !w.synced);
|
||||||
const el = document.getElementById('sync-status');
|
const el = document.getElementById('sync-status');
|
||||||
|
|||||||
+102
-6
@@ -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() {
|
export function getDirtyWorkouts() {
|
||||||
return state.workouts.filter(w => !w.synced);
|
return state.workouts.filter(w => !w.synced);
|
||||||
@@ -10,9 +26,89 @@ export async function sync() {
|
|||||||
alert('Нет данных для синхронизации');
|
alert('Нет данных для синхронизации');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// TODO: заменить на fetch('/api/sync', ...)
|
|
||||||
console.log('Syncing:', JSON.stringify(dirty, null, 2));
|
const btn = document.querySelector('.header-actions .btn-secondary');
|
||||||
dirty.forEach(w => w.synced = true);
|
if (btn) {
|
||||||
saveState();
|
btn.disabled = true;
|
||||||
alert(`Отправлено тренировок: ${dirty.length}`);
|
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 = '🔄';
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user