Critical Stability & Performance fixes. Excessive Log Set button gone on QIuck Log screen
This commit is contained in:
56
src/services/api.ts
Normal file
56
src/services/api.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
const API_URL = '/api';
|
||||
|
||||
export const getAuthToken = () => localStorage.getItem('token');
|
||||
export const setAuthToken = (token: string) => localStorage.setItem('token', token);
|
||||
export const removeAuthToken = () => localStorage.removeItem('token');
|
||||
|
||||
const headers = () => {
|
||||
const token = getAuthToken();
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {})
|
||||
};
|
||||
};
|
||||
|
||||
export const api = {
|
||||
get: async (endpoint: string) => {
|
||||
const res = await fetch(`${API_URL}${endpoint}`, { headers: headers() });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
},
|
||||
post: async (endpoint: string, data: any) => {
|
||||
const res = await fetch(`${API_URL}${endpoint}`, {
|
||||
method: 'POST',
|
||||
headers: headers(),
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
},
|
||||
put: async (endpoint: string, data: any) => {
|
||||
const res = await fetch(`${API_URL}${endpoint}`, {
|
||||
method: 'PUT',
|
||||
headers: headers(),
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
},
|
||||
delete: async (endpoint: string) => {
|
||||
const res = await fetch(`${API_URL}${endpoint}`, {
|
||||
method: 'DELETE',
|
||||
headers: headers()
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
},
|
||||
patch: async (endpoint: string, data: any) => {
|
||||
const res = await fetch(`${API_URL}${endpoint}`, {
|
||||
method: 'PATCH',
|
||||
headers: headers(),
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
}
|
||||
};
|
||||
106
src/services/auth.ts
Normal file
106
src/services/auth.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { User, UserRole, UserProfile } from '../types';
|
||||
import { api, setAuthToken, removeAuthToken } from './api';
|
||||
|
||||
export const getUsers = async (): Promise<{ success: boolean; users?: User[]; error?: string }> => {
|
||||
try {
|
||||
const res = await api.get('/auth/users');
|
||||
return res;
|
||||
} catch (e) {
|
||||
return { success: false, error: 'Failed to fetch users' };
|
||||
}
|
||||
};
|
||||
|
||||
export const login = async (email: string, password: string): Promise<{ success: boolean; user?: User; error?: string }> => {
|
||||
try {
|
||||
const res = await api.post('/auth/login', { email, password });
|
||||
if (res.success) {
|
||||
setAuthToken(res.token);
|
||||
return { success: true, user: res.user };
|
||||
}
|
||||
return { success: false, error: res.error };
|
||||
} catch (e: any) {
|
||||
try {
|
||||
const err = JSON.parse(e.message);
|
||||
return { success: false, error: err.error || 'Login failed' };
|
||||
} catch {
|
||||
return { success: false, error: 'Login failed' };
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const createUser = async (email: string, password: string): Promise<{ success: boolean; error?: string }> => {
|
||||
try {
|
||||
const res = await api.post('/auth/register', { email, password });
|
||||
if (res.success) {
|
||||
setAuthToken(res.token);
|
||||
return { success: true };
|
||||
}
|
||||
return { success: false, error: res.error };
|
||||
} catch (e: any) {
|
||||
try {
|
||||
const err = JSON.parse(e.message);
|
||||
return { success: false, error: err.error || 'Registration failed' };
|
||||
} catch {
|
||||
return { success: false, error: 'Registration failed' };
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteUser = async (userId: string) => {
|
||||
try {
|
||||
const res = await api.delete(`/auth/users/${userId}`);
|
||||
return res;
|
||||
} catch (e) {
|
||||
return { success: false, error: 'Failed to delete user' };
|
||||
}
|
||||
};
|
||||
|
||||
export const toggleBlockUser = async (userId: string, block: boolean) => {
|
||||
try {
|
||||
const res = await api.patch(`/auth/users/${userId}/block`, { block });
|
||||
return res;
|
||||
} catch (e) {
|
||||
return { success: false, error: 'Failed to update user status' };
|
||||
}
|
||||
};
|
||||
|
||||
export const adminResetPassword = (userId: string, newPass: string) => {
|
||||
// Admin only
|
||||
};
|
||||
|
||||
export const updateUserProfile = async (userId: string, profile: Partial<UserProfile>): Promise<{ success: boolean; error?: string }> => {
|
||||
try {
|
||||
const res = await api.patch('/auth/profile', { userId, profile });
|
||||
return res;
|
||||
} catch (e) {
|
||||
return { success: false, error: 'Failed to update profile' };
|
||||
}
|
||||
};
|
||||
|
||||
export const changePassword = async (userId: string, newPassword: string) => {
|
||||
try {
|
||||
const res = await api.post('/auth/change-password', { userId, newPassword });
|
||||
if (!res.success) {
|
||||
console.error('Failed to change password:', res.error);
|
||||
}
|
||||
return res;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return { success: false, error: 'Network error' };
|
||||
}
|
||||
};
|
||||
|
||||
export const getCurrentUserProfile = (userId: string): UserProfile | undefined => {
|
||||
// This was synchronous. Now it needs to be async or fetched on load.
|
||||
// For now, we return undefined and let the app fetch it.
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const getMe = async (): Promise<{ success: boolean; user?: User; error?: string }> => {
|
||||
try {
|
||||
const res = await api.get('/auth/me');
|
||||
return res;
|
||||
} catch (e) {
|
||||
return { success: false, error: 'Failed to fetch user' };
|
||||
}
|
||||
};
|
||||
127
src/services/geminiService.ts
Normal file
127
src/services/geminiService.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { WorkoutSession, UserProfile, WorkoutPlan } from '../types';
|
||||
import { api } from './api';
|
||||
import { generateId } from '../utils/uuid';
|
||||
|
||||
interface FitnessChatOptions {
|
||||
history: WorkoutSession[];
|
||||
userProfile?: UserProfile;
|
||||
plans?: WorkoutPlan[];
|
||||
lang?: 'en' | 'ru';
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
export const createFitnessChat = (
|
||||
history: WorkoutSession[],
|
||||
lang: 'en' | 'ru' = 'ru',
|
||||
userProfile?: UserProfile,
|
||||
plans?: WorkoutPlan[]
|
||||
): any => {
|
||||
// Generate a unique session ID for this chat instance
|
||||
const sessionId = generateId();
|
||||
|
||||
// Summarize workout history
|
||||
const workoutSummary = history.slice(0, 20).map(s => ({
|
||||
date: new Date(s.startTime).toLocaleDateString(lang === 'ru' ? 'ru-RU' : 'en-US'),
|
||||
userWeight: s.userBodyWeight,
|
||||
planName: s.planName,
|
||||
exercises: s.sets.map(set => {
|
||||
const parts = [];
|
||||
parts.push(set.exerciseName);
|
||||
if (set.weight) parts.push(`${set.weight}${lang === 'ru' ? 'кг' : 'kg'}`);
|
||||
if (set.reps) parts.push(`${set.reps}${lang === 'ru' ? 'повт' : 'reps'}`);
|
||||
if (set.distanceMeters) parts.push(`${set.distanceMeters}${lang === 'ru' ? 'м' : 'm'}`);
|
||||
if (set.durationSeconds) parts.push(`${set.durationSeconds}${lang === 'ru' ? 'сек' : 's'}`);
|
||||
return parts.join(' ');
|
||||
}).join(', ')
|
||||
}));
|
||||
|
||||
// Calculate personal records
|
||||
const exerciseRecords = new Map<string, { maxWeight?: number; maxReps?: number; maxDistance?: number }>();
|
||||
history.forEach(session => {
|
||||
session.sets.forEach(set => {
|
||||
const current = exerciseRecords.get(set.exerciseName) || {};
|
||||
exerciseRecords.set(set.exerciseName, {
|
||||
maxWeight: Math.max(current.maxWeight || 0, set.weight || 0),
|
||||
maxReps: Math.max(current.maxReps || 0, set.reps || 0),
|
||||
maxDistance: Math.max(current.maxDistance || 0, set.distanceMeters || 0)
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const personalRecords = Array.from(exerciseRecords.entries()).map(([name, records]) => ({
|
||||
exercise: name,
|
||||
...records
|
||||
}));
|
||||
|
||||
// Build comprehensive system instruction
|
||||
const systemInstruction = lang === 'ru' ? `
|
||||
Ты — опытный и поддерживающий фитнес-тренер AI Coach.
|
||||
Твоя задача — анализировать тренировки пользователя и давать краткие, полезные советы на русском языке.
|
||||
|
||||
ДАННЫЕ ПОЛЬЗОВАТЕЛЯ:
|
||||
${userProfile ? `
|
||||
- Вес: ${userProfile.weight || 'не указан'} кг
|
||||
- Рост: ${userProfile.height || 'не указан'} см
|
||||
- Пол: ${userProfile.gender || 'не указан'}
|
||||
` : 'Профиль не заполнен'}
|
||||
|
||||
ТРЕНИРОВОЧНЫЕ ПЛАНЫ:
|
||||
${plans && plans.length > 0 ? JSON.stringify(plans.map(p => ({ name: p.name, exercises: p.steps.map(s => s.exerciseName) }))) : 'Нет активных планов'}
|
||||
|
||||
ИСТОРИЯ ТРЕНИРОВОК (последние 20):
|
||||
${JSON.stringify(workoutSummary, null, 2)}
|
||||
|
||||
ЛИЧНЫЕ РЕКОРДЫ:
|
||||
${JSON.stringify(personalRecords, null, 2)}
|
||||
|
||||
ИНСТРУКЦИИ:
|
||||
- Используй эти данные для анализа прогресса и ответов на вопросы
|
||||
- Учитывай вес пользователя при анализе упражнений с собственным весом
|
||||
- Будь конкретным и ссылайся на реальные данные из истории
|
||||
- Отвечай емко, мотивирующе
|
||||
- Если данных недостаточно для ответа, честно скажи об этом
|
||||
` : `
|
||||
You are an experienced and supportive fitness coach called AI Coach.
|
||||
Your task is to analyze the user's workouts and provide concise, helpful advice in English.
|
||||
|
||||
USER PROFILE:
|
||||
${userProfile ? `
|
||||
- Weight: ${userProfile.weight || 'not specified'} kg
|
||||
- Height: ${userProfile.height || 'not specified'} cm
|
||||
- Gender: ${userProfile.gender || 'not specified'}
|
||||
` : 'Profile not filled'}
|
||||
|
||||
WORKOUT PLANS:
|
||||
${plans && plans.length > 0 ? JSON.stringify(plans.map(p => ({ name: p.name, exercises: p.steps.map(s => s.exerciseName) }))) : 'No active plans'}
|
||||
|
||||
WORKOUT HISTORY (last 20 sessions):
|
||||
${JSON.stringify(workoutSummary, null, 2)}
|
||||
|
||||
PERSONAL RECORDS:
|
||||
${JSON.stringify(personalRecords, null, 2)}
|
||||
|
||||
INSTRUCTIONS:
|
||||
- Use this data to analyze progress and answer questions
|
||||
- Consider user's weight when analyzing bodyweight exercises
|
||||
- Be specific and reference actual data from the history
|
||||
- Answer concisely and motivationally
|
||||
- If there's insufficient data to answer, be honest about it
|
||||
- ALWAYS answer in the language the user speaks to you
|
||||
`;
|
||||
|
||||
return {
|
||||
sendMessage: async (userMessage: string) => {
|
||||
const res = await api.post('/ai/chat', {
|
||||
systemInstruction,
|
||||
userMessage,
|
||||
sessionId
|
||||
});
|
||||
return {
|
||||
text: res.response,
|
||||
response: {
|
||||
text: () => res.response
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
};
|
||||
352
src/services/i18n.ts
Normal file
352
src/services/i18n.ts
Normal file
@@ -0,0 +1,352 @@
|
||||
|
||||
import { Language } from '../types';
|
||||
|
||||
export const getSystemLanguage = (): Language => {
|
||||
if (typeof navigator === 'undefined') return 'en';
|
||||
const lang = navigator.language.split('-')[0];
|
||||
return lang === 'ru' ? 'ru' : 'en';
|
||||
};
|
||||
|
||||
const translations = {
|
||||
en: {
|
||||
// Tabs
|
||||
tab_tracker: 'Tracker',
|
||||
tab_plans: 'Plans',
|
||||
tab_history: 'History',
|
||||
tab_stats: 'Stats',
|
||||
tab_ai: 'AI Coach',
|
||||
tab_profile: 'Profile',
|
||||
|
||||
// Auth
|
||||
login_title: 'GymFlow AI',
|
||||
login_email: 'Email',
|
||||
login_password: 'Password',
|
||||
login_btn: 'Login',
|
||||
login_contact_admin: 'Contact administrator to get an account.',
|
||||
login_error: 'Invalid email or password',
|
||||
login_password_short: 'Password too short',
|
||||
change_pass_title: 'Change Password',
|
||||
change_pass_desc: 'This is your first login. Please set a new password.',
|
||||
change_pass_new: 'New Password',
|
||||
change_pass_save: 'Save & Login',
|
||||
change_pass_error: 'Error changing password',
|
||||
passwords_mismatch: 'Passwords do not match',
|
||||
register_title: 'Create Account',
|
||||
confirm_password: 'Confirm Password',
|
||||
register_btn: 'Register',
|
||||
have_account: 'Already have an account? Login',
|
||||
need_account: 'Need an account? Register',
|
||||
|
||||
// Tracker
|
||||
ready_title: 'Ready?',
|
||||
ready_subtitle: 'Start your workout and break records.',
|
||||
my_weight: 'My Weight (kg)',
|
||||
change_in_profile: 'Change in profile',
|
||||
free_workout: 'Free Workout',
|
||||
or_choose_plan: 'Or choose a plan',
|
||||
exercises_count: 'exercises',
|
||||
prep_title: 'Preparation',
|
||||
prep_no_instructions: 'No specific instructions.',
|
||||
cancel: 'Cancel',
|
||||
quit: 'Quit',
|
||||
start: 'Start',
|
||||
finish: 'Finish',
|
||||
finish_confirm_title: 'Finish Workout?',
|
||||
finish_confirm_msg: 'Are you sure you want to finish this workout? Your progress will be saved.',
|
||||
quit_no_save: 'Quit without saving',
|
||||
quit_confirm_title: 'Quit without saving?',
|
||||
quit_confirm_msg: 'All progress from this session will be lost. This action cannot be undone.',
|
||||
confirm: 'Confirm',
|
||||
plan_completed: 'Plan Completed!',
|
||||
step: 'Step',
|
||||
of: 'of',
|
||||
select_exercise: 'Select Exercise',
|
||||
weight_kg: 'Weight (kg)',
|
||||
reps: 'Reps',
|
||||
time_sec: 'Time (sec)',
|
||||
dist_m: 'Dist (m)',
|
||||
height_cm: 'Height (cm)',
|
||||
body_weight_percent: 'Body Weight',
|
||||
log_set: 'Log Set',
|
||||
prev: 'Prev',
|
||||
history_section: 'History',
|
||||
create_exercise: 'New Exercise',
|
||||
ex_name: 'Name',
|
||||
ex_type: 'Type',
|
||||
create_btn: 'Create',
|
||||
completed_session_sets: 'Completed in this session',
|
||||
add_weight: 'Add. Weight',
|
||||
no_exercises_found: 'No exercises found',
|
||||
|
||||
// Types
|
||||
type_strength: 'Free Weights & Machines',
|
||||
type_bodyweight: 'Bodyweight',
|
||||
type_cardio: 'Cardio',
|
||||
type_static: 'Static',
|
||||
type_height: 'Height',
|
||||
type_dist: 'Length',
|
||||
type_jump: 'Plyo',
|
||||
|
||||
// History
|
||||
history_empty: 'History is empty. Finish your first workout!',
|
||||
sets_count: 'Sets',
|
||||
finished: 'Finished',
|
||||
delete_workout: 'Delete workout?',
|
||||
delete_set: 'Delete set?',
|
||||
delete_confirm: 'This action cannot be undone.',
|
||||
delete_set_confirm: 'Are you sure you want to delete this set?',
|
||||
delete: 'Delete',
|
||||
edit: 'Edit',
|
||||
save: 'Save',
|
||||
start_time: 'Start',
|
||||
end_time: 'End',
|
||||
max: 'Max',
|
||||
upto: 'Up to',
|
||||
no_plan: 'No plan',
|
||||
|
||||
// Plans
|
||||
plans_empty: 'No plans created',
|
||||
plan_editor: 'Plan Editor',
|
||||
plan_name_ph: 'E.g. Full-body Routine',
|
||||
plan_desc_ph: 'Describe preparation...',
|
||||
exercises_list: 'Exercises',
|
||||
weighted: 'Weighted',
|
||||
add_exercise: 'Add Exercise',
|
||||
my_plans: 'My Plans',
|
||||
|
||||
// Stats
|
||||
progress: 'Progress',
|
||||
volume_title: 'Work Volume',
|
||||
volume_subtitle: 'Tonnage (kg * reps)',
|
||||
sets_title: 'Number of Sets',
|
||||
weight_title: 'Body Weight History',
|
||||
not_enough_data: 'Not enough data for statistics. Complete a few workouts!',
|
||||
|
||||
// AI
|
||||
ai_expert: 'AI Coach',
|
||||
ai_intro: 'Hi! I am your AI coach. I analyzed your workouts. Ask me about progress, technique, or routine.',
|
||||
ai_typing: 'Typing...',
|
||||
ai_placeholder: 'Ask about workouts...',
|
||||
ai_error: 'API Key not configured',
|
||||
|
||||
// Profile
|
||||
profile_title: 'Profile',
|
||||
logout: 'Logout',
|
||||
personal_data: 'Personal Data',
|
||||
height: 'Height (cm)',
|
||||
birth_date: 'Birth Date',
|
||||
gender: 'Gender',
|
||||
male: 'Male',
|
||||
female: 'Female',
|
||||
other: 'Other',
|
||||
save_profile: 'Save Profile',
|
||||
change_pass_btn: 'Change Password',
|
||||
admin_area: 'Manage Users',
|
||||
create_user: 'Create User',
|
||||
user_created: 'User created',
|
||||
language: 'Language',
|
||||
admin_users_list: 'Users List',
|
||||
block: 'Block',
|
||||
unblock: 'Unblock',
|
||||
reset_pass: 'Reset Pass',
|
||||
delete_account: 'Delete Account',
|
||||
delete_account_confirm: 'Are you sure? All your data (sessions, plans) will be permanently deleted.',
|
||||
user_deleted: 'User deleted',
|
||||
pass_reset: 'Password reset',
|
||||
manage_exercises: 'Manage Exercises',
|
||||
archive: 'Archive',
|
||||
unarchive: 'Unarchive',
|
||||
show_archived: 'Show Archived',
|
||||
filter_by_name: 'Filter by name',
|
||||
type_to_filter: 'Type to filter...',
|
||||
exercise_name_exists: 'An exercise with this name already exists',
|
||||
profile_saved: 'Profile saved successfully',
|
||||
|
||||
// Sporadic Sets
|
||||
quick_log: 'Quick Log',
|
||||
sporadic_sets_title: 'Quick Logged Sets',
|
||||
log_sporadic_success: 'Set logged successfully',
|
||||
sporadic_set_note: 'Note (optional)',
|
||||
done: 'Done',
|
||||
saved: 'Saved',
|
||||
|
||||
// Unilateral exercises
|
||||
unilateral_exercise: 'Unilateral exercise (separate left/right tracking)',
|
||||
unilateral: 'Unilateral',
|
||||
same_values_both_sides: 'Same values for both sides',
|
||||
left: 'Left',
|
||||
right: 'Right',
|
||||
},
|
||||
ru: {
|
||||
// Tabs
|
||||
tab_tracker: 'Трекер',
|
||||
tab_plans: 'Планы',
|
||||
tab_history: 'История',
|
||||
tab_stats: 'Статы',
|
||||
tab_ai: 'AI Тренер',
|
||||
tab_profile: 'Профиль',
|
||||
|
||||
// Auth
|
||||
login_title: 'GymFlow AI',
|
||||
login_email: 'Email',
|
||||
login_password: 'Пароль',
|
||||
login_btn: 'Войти',
|
||||
login_contact_admin: 'Свяжитесь с администратором для получения учетной записи.',
|
||||
login_error: 'Неверный email или пароль',
|
||||
login_password_short: 'Пароль слишком короткий',
|
||||
change_pass_title: 'Смена пароля',
|
||||
change_pass_desc: 'Это ваш первый вход. Пожалуйста, установите новый пароль.',
|
||||
change_pass_new: 'Новый пароль',
|
||||
change_pass_save: 'Сохранить и войти',
|
||||
change_pass_error: 'Ошибка смены пароля',
|
||||
passwords_mismatch: 'Пароли не совпадают',
|
||||
register_title: 'Регистрация',
|
||||
confirm_password: 'Подтвердите пароль',
|
||||
register_btn: 'Зарегистрироваться',
|
||||
have_account: 'Уже есть аккаунт? Войти',
|
||||
need_account: 'Нет аккаунта? Регистрация',
|
||||
|
||||
// Tracker
|
||||
ready_title: 'Готовы?',
|
||||
ready_subtitle: 'Начните тренировку и побейте рекорды.',
|
||||
my_weight: 'Мой вес (кг)',
|
||||
change_in_profile: 'Можно изменить в профиле',
|
||||
free_workout: 'Свободная тренировка',
|
||||
or_choose_plan: 'Или выберите план',
|
||||
exercises_count: 'упражнений',
|
||||
prep_title: 'Подготовка',
|
||||
prep_no_instructions: 'Нет особых указаний.',
|
||||
cancel: 'Отмена',
|
||||
start: 'Начать',
|
||||
finish: 'Завершить',
|
||||
finish_confirm_title: 'Завершить тренировку?',
|
||||
finish_confirm_msg: 'Вы уверены, что хотите завершить эту тренировку? Ваш прогресс будет сохранен.',
|
||||
quit_no_save: 'Выйти без сохранения',
|
||||
quit_confirm_title: 'Выйти без сохранения?',
|
||||
quit_confirm_msg: 'Весь прогресс этой сессии будет потерян. Это действие нельзя отменить.',
|
||||
confirm: 'Подтвердить',
|
||||
plan_completed: 'План выполнен!',
|
||||
step: 'Шаг',
|
||||
of: 'из',
|
||||
select_exercise: 'Выберите упражнение',
|
||||
weight_kg: 'Вес (кг)',
|
||||
reps: 'Повторения',
|
||||
time_sec: 'Время (сек)',
|
||||
dist_m: 'Дистанция (м)',
|
||||
height_cm: 'Высота (см)',
|
||||
body_weight_percent: 'Вес тела',
|
||||
log_set: 'Записать подход',
|
||||
prev: 'Предыдущий',
|
||||
history_section: 'История',
|
||||
create_exercise: 'Новое упражнение',
|
||||
ex_name: 'Название',
|
||||
ex_type: 'Тип упражнения',
|
||||
create_btn: 'Создать',
|
||||
completed_session_sets: 'Выполнено в этой тренировке',
|
||||
add_weight: 'Доп. вес',
|
||||
no_exercises_found: 'Упражнения не найдены',
|
||||
|
||||
// Types
|
||||
type_strength: 'Свободные веса и тренажеры',
|
||||
type_bodyweight: 'Свой вес',
|
||||
type_cardio: 'Кардио',
|
||||
type_static: 'Статика',
|
||||
type_height: 'Высота',
|
||||
type_dist: 'Длина',
|
||||
type_jump: 'Прыжки',
|
||||
|
||||
// History
|
||||
history_empty: 'История пуста. Завершите свою первую тренировку!',
|
||||
sets_count: 'Сетов',
|
||||
finished: 'Завершено',
|
||||
delete_workout: 'Удалить тренировку?',
|
||||
delete_set: 'Удалить подход?',
|
||||
delete_confirm: 'Это действие нельзя отменить.',
|
||||
delete_set_confirm: 'Вы уверены, что хотите удалить этот подход?',
|
||||
delete: 'Удалить',
|
||||
edit: 'Редактировать',
|
||||
save: 'Сохранить',
|
||||
start_time: 'Начало',
|
||||
end_time: 'Конец',
|
||||
max: 'Макс',
|
||||
upto: 'До',
|
||||
no_plan: 'Без плана',
|
||||
|
||||
// Plans
|
||||
plans_empty: 'Нет созданных планов',
|
||||
plan_editor: 'Редактор плана',
|
||||
plan_name_ph: 'Например: Комплекс на всё тело',
|
||||
plan_desc_ph: 'Опишите подготовку...',
|
||||
exercises_list: 'Упражнения',
|
||||
weighted: 'С отягощением',
|
||||
add_exercise: 'Добавить упражнение',
|
||||
my_plans: 'Мои планы',
|
||||
|
||||
// Stats
|
||||
progress: 'Прогресс',
|
||||
volume_title: 'Объем работы',
|
||||
volume_subtitle: 'Тоннаж (кг * повторения)',
|
||||
sets_title: 'Количество сетов',
|
||||
weight_title: 'История веса тела',
|
||||
not_enough_data: 'Недостаточно данных для статистики. Проведите хотя бы пару тренировок!',
|
||||
|
||||
// AI
|
||||
ai_expert: 'AI Эксперт',
|
||||
ai_intro: 'Привет! Я твой AI-тренер. Я проанализировал твои тренировки. Спрашивай меня о прогрессе, технике или плане занятий.',
|
||||
ai_typing: 'Печатает...',
|
||||
ai_placeholder: 'Спроси о тренировках...',
|
||||
ai_error: 'API ключ не настроен',
|
||||
|
||||
// Profile
|
||||
profile_title: 'Профиль',
|
||||
logout: 'Выйти',
|
||||
personal_data: 'Личные данные',
|
||||
height: 'Рост (см)',
|
||||
birth_date: 'Дата рожд.',
|
||||
gender: 'Пол',
|
||||
male: 'Мужской',
|
||||
female: 'Женский',
|
||||
other: 'Другой',
|
||||
save_profile: 'Сохранить профиль',
|
||||
change_pass_btn: 'Сменить пароль',
|
||||
admin_area: 'Управление пользователями',
|
||||
create_user: 'Создать пользователя',
|
||||
user_created: 'Пользователь создан',
|
||||
language: 'Язык / Language',
|
||||
admin_users_list: 'Список пользователей',
|
||||
block: 'Блок',
|
||||
unblock: 'Разблок',
|
||||
reset_pass: 'Сброс пароля',
|
||||
delete_account: 'Удалить аккаунт',
|
||||
delete_account_confirm: 'Вы уверены? Все ваши данные (сессии, планы) будут безвозвратно удалены.',
|
||||
user_deleted: 'Пользователь удален',
|
||||
pass_reset: 'Пароль сброшен',
|
||||
manage_exercises: 'Управление упражнениями',
|
||||
archive: 'Архив',
|
||||
unarchive: 'Вернуть',
|
||||
show_archived: 'Показать архивные',
|
||||
filter_by_name: 'Фильтр по названию',
|
||||
type_to_filter: 'Введите для фильтрации...',
|
||||
exercise_name_exists: 'Упражнение с таким названием уже существует',
|
||||
profile_saved: 'Профиль успешно сохранен',
|
||||
|
||||
// Sporadic Sets
|
||||
quick_log: 'Быстрая запись',
|
||||
sporadic_sets_title: 'Быстрые записи',
|
||||
log_sporadic_success: 'Сет записан',
|
||||
sporadic_set_note: 'Заметка (опц.)',
|
||||
done: 'Готово',
|
||||
saved: 'Сохранено',
|
||||
|
||||
// Unilateral exercises
|
||||
unilateral_exercise: 'Односторонее упражнение (отдельно левая/правая)',
|
||||
unilateral: 'Одностороннее',
|
||||
same_values_both_sides: 'Одинаковые значения для обеих сторон',
|
||||
left: 'Левая',
|
||||
right: 'Правая',
|
||||
},
|
||||
};
|
||||
|
||||
export const t = (key: keyof typeof translations['en'], lang: Language) => {
|
||||
return translations[lang][key] || translations['en'][key] || key;
|
||||
};
|
||||
69
src/services/sporadicSets.ts
Normal file
69
src/services/sporadicSets.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { api } from './api';
|
||||
import { SporadicSet } from '../types';
|
||||
|
||||
export async function getSporadicSets(): Promise<SporadicSet[]> {
|
||||
try {
|
||||
const response = await api.get('/sporadic-sets');
|
||||
if (response.success) {
|
||||
return response.sporadicSets || [];
|
||||
}
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch sporadic sets:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function logSporadicSet(setData: {
|
||||
exerciseId: string;
|
||||
weight?: number;
|
||||
reps?: number;
|
||||
durationSeconds?: number;
|
||||
distanceMeters?: number;
|
||||
height?: number;
|
||||
bodyWeightPercentage?: number;
|
||||
note?: string;
|
||||
side?: 'LEFT' | 'RIGHT';
|
||||
}): Promise<SporadicSet | null> {
|
||||
try {
|
||||
const response = await api.post('/sporadic-sets', setData);
|
||||
if (response.success) {
|
||||
return response.sporadicSet;
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Failed to log sporadic set:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateSporadicSet(id: string, setData: {
|
||||
weight?: number;
|
||||
reps?: number;
|
||||
durationSeconds?: number;
|
||||
distanceMeters?: number;
|
||||
height?: number;
|
||||
bodyWeightPercentage?: number;
|
||||
note?: string;
|
||||
}): Promise<SporadicSet | null> {
|
||||
try {
|
||||
const response = await api.put(`/sporadic-sets/${id}`, setData);
|
||||
if (response.success) {
|
||||
return response.sporadicSet;
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Failed to update sporadic set:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteSporadicSet(id: string): Promise<boolean> {
|
||||
try {
|
||||
const response = await api.delete(`/sporadic-sets/${id}`);
|
||||
return response.success || false;
|
||||
} catch (error) {
|
||||
console.error('Failed to delete sporadic set:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
114
src/services/storage.ts
Normal file
114
src/services/storage.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { WorkoutSession, ExerciseDef, ExerciseType, WorkoutSet, WorkoutPlan } from '../types';
|
||||
import { api } from './api';
|
||||
|
||||
export const getSessions = async (userId: string): Promise<WorkoutSession[]> => {
|
||||
try {
|
||||
const sessions = await api.get('/sessions');
|
||||
// Convert ISO date strings to timestamps
|
||||
return sessions.map((session: any) => ({
|
||||
...session,
|
||||
startTime: new Date(session.startTime).getTime(),
|
||||
endTime: session.endTime ? new Date(session.endTime).getTime() : undefined,
|
||||
sets: session.sets.map((set: any) => ({
|
||||
...set,
|
||||
exerciseName: set.exercise?.name || 'Unknown',
|
||||
type: set.exercise?.type || 'STRENGTH'
|
||||
}))
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export const saveSession = async (userId: string, session: WorkoutSession): Promise<void> => {
|
||||
await api.post('/sessions', session);
|
||||
};
|
||||
|
||||
export const getActiveSession = async (userId: string): Promise<WorkoutSession | null> => {
|
||||
try {
|
||||
const response = await api.get('/sessions/active');
|
||||
if (!response.success || !response.session) {
|
||||
return null;
|
||||
}
|
||||
const session = response.session;
|
||||
// Convert ISO date strings to timestamps
|
||||
return {
|
||||
...session,
|
||||
startTime: new Date(session.startTime).getTime(),
|
||||
endTime: session.endTime ? new Date(session.endTime).getTime() : undefined,
|
||||
sets: session.sets.map((set: any) => ({
|
||||
...set,
|
||||
exerciseName: set.exercise?.name || 'Unknown',
|
||||
type: set.exercise?.type || 'STRENGTH'
|
||||
}))
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const updateActiveSession = async (userId: string, session: WorkoutSession): Promise<void> => {
|
||||
await api.put('/sessions/active', session);
|
||||
};
|
||||
|
||||
export const deleteSetFromActiveSession = async (userId: string, setId: string): Promise<void> => {
|
||||
await api.delete(`/sessions/active/set/${setId}`);
|
||||
};
|
||||
|
||||
export const updateSetInActiveSession = async (userId: string, setId: string, setData: Partial<WorkoutSet>): Promise<WorkoutSet> => {
|
||||
const response = await api.put(`/sessions/active/set/${setId}`, setData);
|
||||
return response.updatedSet;
|
||||
};
|
||||
|
||||
export const deleteActiveSession = async (userId: string): Promise<void> => {
|
||||
await api.delete('/sessions/active');
|
||||
};
|
||||
|
||||
export const deleteSession = async (userId: string, id: string): Promise<void> => {
|
||||
await api.delete(`/sessions/${id}`);
|
||||
};
|
||||
|
||||
export const deleteAllUserData = (userId: string) => {
|
||||
// Not implemented in frontend
|
||||
};
|
||||
|
||||
export const getExercises = async (userId: string): Promise<ExerciseDef[]> => {
|
||||
try {
|
||||
return await api.get('/exercises');
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export const saveExercise = async (userId: string, exercise: ExerciseDef): Promise<void> => {
|
||||
await api.post('/exercises', exercise);
|
||||
};
|
||||
|
||||
export const getLastSetForExercise = async (userId: string, exerciseId: string): Promise<WorkoutSet | undefined> => {
|
||||
try {
|
||||
const response = await api.get(`/exercises/${exerciseId}/last-set`);
|
||||
if (response.success && response.set) {
|
||||
return response.set;
|
||||
}
|
||||
return undefined;
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch last set:", error);
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
export const getPlans = async (userId: string): Promise<WorkoutPlan[]> => {
|
||||
try {
|
||||
return await api.get('/plans');
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export const savePlan = async (userId: string, plan: WorkoutPlan): Promise<void> => {
|
||||
await api.post('/plans', plan);
|
||||
};
|
||||
|
||||
export const deletePlan = async (userId: string, id: string): Promise<void> => {
|
||||
await api.delete(`/plans/${id}`);
|
||||
};
|
||||
53
src/services/weight.ts
Normal file
53
src/services/weight.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { BodyWeightRecord } from '../types';
|
||||
|
||||
const API_URL = '/api';
|
||||
|
||||
export const getWeightHistory = async (): Promise<BodyWeightRecord[]> => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) return [];
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/weight`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch weight history');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Error fetching weight history:', error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export const logWeight = async (weight: number, dateStr?: string): Promise<BodyWeightRecord | null> => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) return null;
|
||||
|
||||
try {
|
||||
// Default to today if no date provided
|
||||
const date = dateStr || new Date().toISOString().split('T')[0];
|
||||
|
||||
const response = await fetch(`${API_URL}/weight`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({ weight, dateStr: date })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to log weight');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Error logging weight:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user