AI Plan generation enhanced
This commit is contained in:
@@ -181,7 +181,7 @@ const SortablePlanStep: React.FC<SortablePlanStepProps> = ({ step, index, toggle
|
|||||||
const Plans: React.FC<PlansProps> = ({ lang }) => {
|
const Plans: React.FC<PlansProps> = ({ lang }) => {
|
||||||
const { currentUser } = useAuth();
|
const { currentUser } = useAuth();
|
||||||
const userId = currentUser?.id || '';
|
const userId = currentUser?.id || '';
|
||||||
const { plans, savePlan, deletePlan, refreshData } = useSession();
|
const { plans, sessions, savePlan, deletePlan, refreshData } = useSession();
|
||||||
const { startSession } = useActiveWorkout();
|
const { startSession } = useActiveWorkout();
|
||||||
|
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
@@ -226,7 +226,9 @@ const Plans: React.FC<PlansProps> = ({ lang }) => {
|
|||||||
const [aiLoading, setAILoading] = useState(false);
|
const [aiLoading, setAILoading] = useState(false);
|
||||||
const [aiError, setAIError] = useState<string | null>(null);
|
const [aiError, setAIError] = useState<string | null>(null);
|
||||||
const [aiDuration, setAIDuration] = useState(60); // Default 1 hour in minutes
|
const [aiDuration, setAIDuration] = useState(60); // Default 1 hour in minutes
|
||||||
const [aiEquipment, setAIEquipment] = useState<'none' | 'essentials' | 'free_weights' | 'full_gym'>('full_gym');
|
const [aiEquipment, setAIEquipment] = useState<'none' | 'essentials' | 'free_weights' | 'full_gym'>('none');
|
||||||
|
const [aiLevel, setAILevel] = useState<'beginner' | 'intermediate' | 'advanced'>('intermediate');
|
||||||
|
const [aiIntensity, setAIIntensity] = useState<'low' | 'moderate' | 'high'>('moderate');
|
||||||
const [generatedPlanPreview, setGeneratedPlanPreview] = useState<{
|
const [generatedPlanPreview, setGeneratedPlanPreview] = useState<{
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
@@ -417,7 +419,17 @@ const Plans: React.FC<PlansProps> = ({ lang }) => {
|
|||||||
? aiPrompt
|
? aiPrompt
|
||||||
: (lang === 'ru' ? 'Создай план тренировки' : 'Create a workout plan');
|
: (lang === 'ru' ? 'Создай план тренировки' : 'Create a workout plan');
|
||||||
|
|
||||||
const aiPlan = await generateWorkoutPlan(prompt, availableNames, lang, aiDuration, aiEquipment);
|
const aiPlan = await generateWorkoutPlan(
|
||||||
|
prompt,
|
||||||
|
availableNames,
|
||||||
|
lang,
|
||||||
|
aiDuration,
|
||||||
|
aiEquipment,
|
||||||
|
aiLevel,
|
||||||
|
aiIntensity,
|
||||||
|
sessions,
|
||||||
|
currentUser?.profile
|
||||||
|
);
|
||||||
setGeneratedPlanPreview(aiPlan);
|
setGeneratedPlanPreview(aiPlan);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('AI plan generation error:', err);
|
console.error('AI plan generation error:', err);
|
||||||
@@ -488,7 +500,9 @@ const Plans: React.FC<PlansProps> = ({ lang }) => {
|
|||||||
// Reset state and close
|
// Reset state and close
|
||||||
setAIPrompt('');
|
setAIPrompt('');
|
||||||
setAIDuration(60);
|
setAIDuration(60);
|
||||||
setAIEquipment('full_gym');
|
setAIEquipment('none');
|
||||||
|
setAILevel('intermediate');
|
||||||
|
setAIIntensity('moderate');
|
||||||
setGeneratedPlanPreview(null);
|
setGeneratedPlanPreview(null);
|
||||||
setShowAISheet(false);
|
setShowAISheet(false);
|
||||||
setFabMenuOpen(false);
|
setFabMenuOpen(false);
|
||||||
@@ -813,6 +827,46 @@ const Plans: React.FC<PlansProps> = ({ lang }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Level Selector */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-on-surface">{t('level', lang)}</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{(['beginner', 'intermediate', 'advanced'] as const).map((lvl) => (
|
||||||
|
<button
|
||||||
|
key={lvl}
|
||||||
|
onClick={() => setAILevel(lvl)}
|
||||||
|
disabled={aiLoading}
|
||||||
|
className={`flex-1 p-2 rounded-xl text-sm font-medium transition-all ${aiLevel === lvl
|
||||||
|
? 'bg-primary text-on-primary'
|
||||||
|
: 'bg-surface-container-high text-on-surface hover:bg-surface-container-highest'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t(`level_${lvl}` as any, lang)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Intensity Selector */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-on-surface">{t('intensity', lang)}</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{(['low', 'moderate', 'high'] as const).map((int) => (
|
||||||
|
<button
|
||||||
|
key={int}
|
||||||
|
onClick={() => setAIIntensity(int)}
|
||||||
|
disabled={aiLoading}
|
||||||
|
className={`flex-1 p-2 rounded-xl text-sm font-medium transition-all ${aiIntensity === int
|
||||||
|
? 'bg-primary text-on-primary'
|
||||||
|
: 'bg-surface-container-high text-on-surface hover:bg-surface-container-highest'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t(`intensity_${int}` as any, lang)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Additional Requirements */}
|
{/* Additional Requirements */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-on-surface">
|
<label className="text-sm font-medium text-on-surface">
|
||||||
|
|||||||
@@ -26,61 +26,22 @@ export const createFitnessChat = (
|
|||||||
// Generate a unique session ID for this chat instance
|
// Generate a unique session ID for this chat instance
|
||||||
const sessionId = generateId();
|
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
|
// Calculate personal records
|
||||||
const exerciseRecords = new Map<string, { maxWeight?: number; maxReps?: number; maxDistance?: number }>();
|
// Reuse the logic via shared helper if possible, or keep as is if we want to include 'plans'.
|
||||||
history.forEach(session => {
|
// The helper getUserContextString currently doesn't include 'plans', so we will use it for history/profile and append plans manually.
|
||||||
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]) => ({
|
const userContext = getUserContextString(history, userProfile, lang);
|
||||||
exercise: name,
|
|
||||||
...records
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Build comprehensive system instruction
|
// Build comprehensive system instruction
|
||||||
const systemInstruction = lang === 'ru' ? `
|
const systemInstruction = lang === 'ru' ? `
|
||||||
Ты — опытный и поддерживающий фитнес-тренер AI Coach.
|
Ты — опытный и поддерживающий фитнес-тренер AI Coach.
|
||||||
Твоя задача — анализировать тренировки пользователя и давать краткие, полезные советы на русском языке.
|
Твоя задача — анализировать тренировки пользователя и давать краткие, полезные советы на русском языке.
|
||||||
|
|
||||||
ДАННЫЕ ПОЛЬЗОВАТЕЛЯ:
|
${userContext}
|
||||||
${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) }))) : 'Нет активных планов'}
|
${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)}
|
|
||||||
|
|
||||||
ИНСТРУКЦИИ:
|
ИНСТРУКЦИИ:
|
||||||
- Используй эти данные для анализа прогресса и ответов на вопросы
|
- Используй эти данные для анализа прогресса и ответов на вопросы
|
||||||
- Учитывай вес пользователя при анализе упражнений с собственным весом
|
- Учитывай вес пользователя при анализе упражнений с собственным весом
|
||||||
@@ -91,22 +52,11 @@ export const createFitnessChat = (
|
|||||||
You are an experienced and supportive fitness coach called AI Coach.
|
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.
|
Your task is to analyze the user's workouts and provide concise, helpful advice in English.
|
||||||
|
|
||||||
USER PROFILE:
|
${userContext}
|
||||||
${userProfile ? `
|
|
||||||
- Weight: ${userProfile.weight || 'not specified'} kg
|
|
||||||
- Height: ${userProfile.height || 'not specified'} cm
|
|
||||||
- Gender: ${userProfile.gender || 'not specified'}
|
|
||||||
` : 'Profile not filled'}
|
|
||||||
|
|
||||||
WORKOUT PLANS:
|
WORKOUT PLANS:
|
||||||
${plans && plans.length > 0 ? JSON.stringify(plans.map(p => ({ name: p.name, exercises: p.steps.map(s => s.exerciseName) }))) : 'No active 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:
|
INSTRUCTIONS:
|
||||||
- Use this data to analyze progress and answer questions
|
- Use this data to analyze progress and answer questions
|
||||||
- Consider user's weight when analyzing bodyweight exercises
|
- Consider user's weight when analyzing bodyweight exercises
|
||||||
@@ -153,32 +103,122 @@ interface AIPlanResponse {
|
|||||||
|
|
||||||
// ... (other code)
|
// ... (other code)
|
||||||
|
|
||||||
|
// Helper to generate user context string
|
||||||
|
const getUserContextString = (history: WorkoutSession[], userProfile: UserProfile | undefined, lang: 'en' | 'ru') => {
|
||||||
|
// Summarize workout history (Last 8 sessions)
|
||||||
|
const workoutSummary = history.slice(0, 8).map(s => ({
|
||||||
|
date: new Date(s.startTime).toLocaleDateString(lang === 'ru' ? 'ru-RU' : 'en-US'),
|
||||||
|
planName: s.planName || (lang === 'ru' ? 'Свободная тренировка' : 'Freestyle'),
|
||||||
|
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'}`);
|
||||||
|
return parts.join(' ');
|
||||||
|
}).join(', ')
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Calculate personal records
|
||||||
|
const exerciseRecords = new Map<string, { maxWeight?: number; maxReps?: 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),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const personalRecords = Array.from(exerciseRecords.entries())
|
||||||
|
.filter(([_, r]) => (r.maxWeight && r.maxWeight > 0) || (r.maxReps && r.maxReps > 10)) // Filter significant records
|
||||||
|
.map(([name, records]) => ({
|
||||||
|
exercise: name,
|
||||||
|
...records
|
||||||
|
}));
|
||||||
|
|
||||||
|
return lang === 'ru' ? `
|
||||||
|
ПРОФИЛЬ ПОЛЬЗОВАТЕЛЯ:
|
||||||
|
${userProfile ? `
|
||||||
|
- Вес: ${userProfile.weight || 'не указан'} кг
|
||||||
|
- Рост: ${userProfile.height || 'не указан'} см
|
||||||
|
- Пол: ${userProfile.gender || 'не указан'}
|
||||||
|
` : 'Профиль не заполнен'}
|
||||||
|
|
||||||
|
ИСТОРИЯ (последние 8 тренировок):
|
||||||
|
${JSON.stringify(workoutSummary, null, 2)}
|
||||||
|
|
||||||
|
ЛИЧНЫЕ РЕКОРДЫ:
|
||||||
|
${JSON.stringify(personalRecords, null, 2)}
|
||||||
|
` : `
|
||||||
|
USER PROFILE:
|
||||||
|
${userProfile ? `
|
||||||
|
- Weight: ${userProfile.weight || 'not specified'} kg
|
||||||
|
- Height: ${userProfile.height || 'not specified'} cm
|
||||||
|
- Gender: ${userProfile.gender || 'not specified'}
|
||||||
|
` : 'Profile not filled'}
|
||||||
|
|
||||||
|
HISTORY (last 8 sessions):
|
||||||
|
${JSON.stringify(workoutSummary, null, 2)}
|
||||||
|
|
||||||
|
PERSONAL RECORDS:
|
||||||
|
${JSON.stringify(personalRecords, null, 2)}
|
||||||
|
`;
|
||||||
|
};
|
||||||
|
|
||||||
export const generateWorkoutPlan = async (
|
export const generateWorkoutPlan = async (
|
||||||
userPrompt: string,
|
userPrompt: string,
|
||||||
availableExercises: string[],
|
availableExercises: string[],
|
||||||
lang: 'en' | 'ru' = 'en',
|
lang: 'en' | 'ru' = 'en',
|
||||||
durationMinutes: number = 60,
|
durationMinutes: number = 60,
|
||||||
equipment: 'none' | 'essentials' | 'free_weights' | 'full_gym' = 'full_gym'
|
equipment: 'none' | 'essentials' | 'free_weights' | 'full_gym' = 'full_gym',
|
||||||
|
level: 'beginner' | 'intermediate' | 'advanced' = 'intermediate',
|
||||||
|
intensity: 'low' | 'moderate' | 'high' = 'moderate',
|
||||||
|
history: WorkoutSession[] = [],
|
||||||
|
userProfile?: UserProfile
|
||||||
): Promise<AIPlanResponse> => {
|
): Promise<AIPlanResponse> => {
|
||||||
const equipmentDescription = t(`ai_equipment_desc_${equipment}` as any, lang);
|
const equipmentDescription = t(`ai_equipment_desc_${equipment}` as any, lang);
|
||||||
|
const levelText = t(`level_${level}` as any, lang);
|
||||||
|
const intensityText = t(`intensity_${intensity}` as any, lang);
|
||||||
|
const userContext = getUserContextString(history, userProfile, lang);
|
||||||
|
|
||||||
const durationText = durationMinutes >= 120
|
const durationText = durationMinutes >= 120
|
||||||
? (lang === 'ru' ? '2+ часа' : '2+ hours')
|
? (lang === 'ru' ? '2+ часа' : '2+ hours')
|
||||||
: `${durationMinutes} ${lang === 'ru' ? 'минут' : 'minutes'}`;
|
: `${durationMinutes} ${lang === 'ru' ? 'минут' : 'minutes'}`;
|
||||||
|
|
||||||
const systemInstruction = lang === 'ru'
|
const systemInstruction = lang === 'ru'
|
||||||
? `Ты — генератор планов тренировок. Верни ТОЛЬКО JSON-объект (без дополнительного текста).
|
? `Ты — генератор планов тренировок. Верни ТОЛЬКО JSON-объект.
|
||||||
|
|
||||||
ПАРАМЕТРЫ ТРЕНИРОВКИ:
|
${userContext}
|
||||||
|
|
||||||
|
ПАРАМЕТРЫ ПЛАНА:
|
||||||
- Длительность: ${durationText}
|
- Длительность: ${durationText}
|
||||||
|
- Уровень: ${levelText}
|
||||||
|
- Интенсивность: ${intensityText}
|
||||||
- Оборудование: ${equipmentDescription}
|
- Оборудование: ${equipmentDescription}
|
||||||
|
|
||||||
ВАЖНО: Каждый элемент массива exercises — это ОДИН подход. Если нужно 3 подхода одного упражнения, оно должно встретиться в списке 3 раза подряд.
|
ВАЖНО:
|
||||||
|
1. Используй историю тренировок и рекорды, чтобы подобрать подходящие веса (или указать их как ориентир в описании) и сложность.
|
||||||
|
2. Проанализируй последние тренировки, чтобы не перегружать недавно работавшие мышцы.
|
||||||
|
3. Каждый элемент массива exercises — это ОДИН подход. Если нужно 3 подхода одного упражнения, оно должно встретиться в списке 3 раза подряд.
|
||||||
|
4. Если план "Full Body" — задействуй ВСЕ крупные мышечные группы.
|
||||||
|
|
||||||
|
ПРАВИЛА НАЗВАНИЙ (ОЧЕНЬ ВАЖНО):
|
||||||
|
1. "Weighted" (с отягощением) — это параметр isWeighted: true, а БОЛЬШЕ НИКОГДА НЕ часть названия.
|
||||||
|
- НЕЛЬЗЯ: "Weighted Pull-ups", "Подтягивания (с весом)"
|
||||||
|
- ПРАВИЛЬНО: "Подтягивания" (isWeighted: true)
|
||||||
|
2. СТРОГО БЕЗ ВАРИАНТОВ ИЛИ ОПЦИЙ.
|
||||||
|
- НЕЛЬЗЯ: "Отжимания (или на коленях)", "Ring Dips (or Barbell)"
|
||||||
|
- ПРАВИЛЬНО: "Отжимания на кольцах"
|
||||||
|
3. СТРОГО БЕЗ ИНСТРУКЦИЙ ПО ТЕХНИКЕ И СКОБОК.
|
||||||
|
- НЕЛЬЗЯ: "Подтягивания (до груди)", "Squats (ass to grass)"
|
||||||
|
- ПРАВИЛЬНО: "Подтягивания", "Приседания"
|
||||||
|
4. Названия должны быть КОРОТКИМИ и ЧИСТЫМИ.
|
||||||
|
|
||||||
Формат ответа:
|
Формат ответа:
|
||||||
{
|
{
|
||||||
"name": "Название плана",
|
"name": "Название плана",
|
||||||
"description": "Описание/инструкции по подготовке",
|
"description": "Описание/инструкции по подготовке (укажи, почему этот план подходит пользователю на основе его истории)",
|
||||||
"exercises": [
|
"exercises": [
|
||||||
{ "name": "Название упражнения", "isWeighted": false, "restTimeSeconds": 60 }
|
{ "name": "Название упражнения", "isWeighted": false, "restTimeSeconds": 60 }
|
||||||
]
|
]
|
||||||
@@ -189,18 +229,38 @@ export const generateWorkoutPlan = async (
|
|||||||
|
|
||||||
Доступные упражнения: ${JSON.stringify(availableExercises)}
|
Доступные упражнения: ${JSON.stringify(availableExercises)}
|
||||||
`
|
`
|
||||||
: `You are a workout plan generator. Return ONLY a JSON object (no extra text).
|
: `You are a workout plan generator. Return ONLY a JSON object.
|
||||||
|
|
||||||
|
${userContext}
|
||||||
|
|
||||||
WORKOUT PARAMETERS:
|
WORKOUT PARAMETERS:
|
||||||
- Duration: ${durationText}
|
- Duration: ${durationText}
|
||||||
|
- Level: ${levelText}
|
||||||
|
- Intensity: ${intensityText}
|
||||||
- Equipment: ${equipmentDescription}
|
- Equipment: ${equipmentDescription}
|
||||||
|
|
||||||
IMPORTANT: Each item in the exercises array represents ONE set. If you need 3 sets of an exercise, it must appear in the list 3 times consecutively.
|
IMPORTANT:
|
||||||
|
1. Use workout history and records to adjust difficulty and suggest weights (in description).
|
||||||
|
2. Analyze recent sessions to avoid overworking recently trained muscles.
|
||||||
|
3. Each item in the exercises array represents ONE set. If you need 3 sets of an exercise, it must appear in the list 3 times consecutively.
|
||||||
|
4. "Full Body" means ALL big muscle groups must be involved.
|
||||||
|
|
||||||
|
EXTREMELY IMPORTANT NAMING RULES:
|
||||||
|
1. "Weighted" must be a parameter (isWeighted: true), NEVER part of the name.
|
||||||
|
- BAD: "Weighted Pull-ups", "Pull-ups (Weighted)"
|
||||||
|
- GOOD: "Pull-ups" (isWeighted: true)
|
||||||
|
2. STRICTLY NO VARIANTS OR ALTERNATIVES.
|
||||||
|
- BAD: "Ring Dips (or Barbell Dips)", "Push-ups (or Kneeling)"
|
||||||
|
- GOOD: "Ring Dips"
|
||||||
|
3. STRICTLY NO FORM NOTES OR PARENTHESES with instructions.
|
||||||
|
- BAD: "Ring Rows (Chest to Rings)", "Squats (ass to grass)"
|
||||||
|
- GOOD: "Ring Rows", "Squats"
|
||||||
|
4. Keep names CLEAN and SHORT.
|
||||||
|
|
||||||
Response format:
|
Response format:
|
||||||
{
|
{
|
||||||
"name": "Plan Name",
|
"name": "Plan Name",
|
||||||
"description": "Description/preparation instructions",
|
"description": "Description/preparation instructions (explain why this fits based on user history)",
|
||||||
"exercises": [
|
"exercises": [
|
||||||
{ "name": "Exercise Name", "isWeighted": false, "restTimeSeconds": 60 }
|
{ "name": "Exercise Name", "isWeighted": false, "restTimeSeconds": 60 }
|
||||||
]
|
]
|
||||||
@@ -209,8 +269,7 @@ If an exercise is NOT in the available list, also add:
|
|||||||
"type": one of STRENGTH, BODYWEIGHT, CARDIO, STATIC, PLYOMETRIC,
|
"type": one of STRENGTH, BODYWEIGHT, CARDIO, STATIC, PLYOMETRIC,
|
||||||
"unilateral": true or false
|
"unilateral": true or false
|
||||||
|
|
||||||
Available exercises: ${JSON.stringify(availableExercises)}
|
Available exercises: ${JSON.stringify(availableExercises)}`;
|
||||||
`;
|
|
||||||
|
|
||||||
const res = await api.post<ApiResponse<{ response: string }>>('/ai/chat', {
|
const res = await api.post<ApiResponse<{ response: string }>>('/ai/chat', {
|
||||||
systemInstruction,
|
systemInstruction,
|
||||||
|
|||||||
@@ -140,9 +140,17 @@ const translations = {
|
|||||||
ai_generated_plan: 'Generated Plan',
|
ai_generated_plan: 'Generated Plan',
|
||||||
regenerate: 'Regenerate',
|
regenerate: 'Regenerate',
|
||||||
ai_equipment_desc_none: 'No equipment - bodyweight exercises only',
|
ai_equipment_desc_none: 'No equipment - bodyweight exercises only',
|
||||||
ai_equipment_desc_essentials: 'Street workout essentials - pull-up bar, dip bar, gymnastics rings',
|
ai_equipment_desc_essentials: 'Street workout essentials - pull-up bar, parallel bars, gymnastics rings',
|
||||||
ai_equipment_desc_free_weights: 'Free weights - dumbbells, kettlebells, barbells',
|
ai_equipment_desc_free_weights: 'Free weights - dumbbells, kettlebells, barbells',
|
||||||
ai_equipment_desc_full_gym: 'Complete gym - all machines and equipment available',
|
ai_equipment_desc_full_gym: 'Complete gym - all machines and equipment available',
|
||||||
|
level: 'Level',
|
||||||
|
level_beginner: 'Beginner',
|
||||||
|
level_intermediate: 'Intermediate',
|
||||||
|
level_advanced: 'Advanced',
|
||||||
|
intensity: 'Intensity',
|
||||||
|
intensity_low: 'Low',
|
||||||
|
intensity_moderate: 'Moderate',
|
||||||
|
intensity_high: 'High',
|
||||||
|
|
||||||
// Stats
|
// Stats
|
||||||
progress: 'Progress',
|
progress: 'Progress',
|
||||||
@@ -349,6 +357,14 @@ const translations = {
|
|||||||
ai_equipment_desc_essentials: 'Street workout - турник, брусья, кольца',
|
ai_equipment_desc_essentials: 'Street workout - турник, брусья, кольца',
|
||||||
ai_equipment_desc_free_weights: 'Свободные веса - гантели, гири, штанга',
|
ai_equipment_desc_free_weights: 'Свободные веса - гантели, гири, штанга',
|
||||||
ai_equipment_desc_full_gym: 'Полный зал - все тренажеры и оборудование',
|
ai_equipment_desc_full_gym: 'Полный зал - все тренажеры и оборудование',
|
||||||
|
level: 'Уровень',
|
||||||
|
level_beginner: 'Новичок',
|
||||||
|
level_intermediate: 'Средний',
|
||||||
|
level_advanced: 'Продвинутый',
|
||||||
|
intensity: 'Интенсивность',
|
||||||
|
intensity_low: 'Низкая',
|
||||||
|
intensity_moderate: 'Средняя',
|
||||||
|
intensity_high: 'Высокая',
|
||||||
|
|
||||||
// Stats
|
// Stats
|
||||||
progress: 'Прогресс',
|
progress: 'Прогресс',
|
||||||
|
|||||||
106
tests/ai-plan-creation.spec.ts
Normal file
106
tests/ai-plan-creation.spec.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import { test, expect } from './fixtures';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
|
||||||
|
test.describe('AI Plan Creation', () => {
|
||||||
|
|
||||||
|
test('2.14 A. Workout Plans - Create Plan with AI (Parametrized)', async ({ page, createUniqueUser }) => {
|
||||||
|
const user = await loginAndSetup(page, createUniqueUser);
|
||||||
|
|
||||||
|
// Mock the AI endpoint
|
||||||
|
await page.route('**/api/ai/chat', async route => {
|
||||||
|
const plan = {
|
||||||
|
name: 'AI Advanced Plan',
|
||||||
|
description: 'Generated High Intensity Plan',
|
||||||
|
exercises: [
|
||||||
|
{ name: 'Mock Push-ups', isWeighted: false, restTimeSeconds: 60, type: 'BODYWEIGHT', unilateral: false },
|
||||||
|
{ name: 'Mock Weighted Pull-ups', isWeighted: true, restTimeSeconds: 90, type: 'BODYWEIGHT', unilateral: false }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
// The service expects { response: "string_or_json" }
|
||||||
|
await route.fulfill({
|
||||||
|
json: {
|
||||||
|
response: JSON.stringify(plan)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Navigate to Plans
|
||||||
|
await page.getByRole('button', { name: 'Plans' }).first().click();
|
||||||
|
|
||||||
|
// Click FAB to open menu
|
||||||
|
const fab = page.getByLabel('Create Plan').or(page.getByRole('button', { name: '+' }));
|
||||||
|
await fab.click();
|
||||||
|
|
||||||
|
// Click "With AI"
|
||||||
|
await page.getByRole('button', { name: 'With AI' }).click();
|
||||||
|
|
||||||
|
// Verify Defaults
|
||||||
|
await expect(page.getByText('Create Plan with AI')).toBeVisible();
|
||||||
|
|
||||||
|
// Equipment default: No equipment
|
||||||
|
// Checking visual state might be hard if it's custom styled, but we can check the selected button style or text
|
||||||
|
const eqSection = page.locator('div').filter({ hasText: 'Equipment' }).last();
|
||||||
|
// Assuming "No equipment" is selected. We can check class or aria-pressed if available, or just proceed to change it.
|
||||||
|
|
||||||
|
// Level default: Intermediate
|
||||||
|
const levelSection = page.locator('div').filter({ hasText: 'Level' }).last();
|
||||||
|
// Just verify buttons exist
|
||||||
|
await expect(levelSection.getByRole('button', { name: 'Intermediate' })).toBeVisible();
|
||||||
|
|
||||||
|
// Intensity default: Moderate
|
||||||
|
const intensitySection = page.locator('div').filter({ hasText: 'Intensity' }).last();
|
||||||
|
await expect(intensitySection.getByRole('button', { name: 'Moderate' })).toBeVisible();
|
||||||
|
|
||||||
|
// Modify Inputs
|
||||||
|
// Change Level to Advanced
|
||||||
|
await levelSection.getByRole('button', { name: 'Advanced' }).click();
|
||||||
|
|
||||||
|
// Change Intensity to High
|
||||||
|
await intensitySection.getByRole('button', { name: 'High' }).click();
|
||||||
|
|
||||||
|
// Change Equipment to Free Weights
|
||||||
|
await eqSection.getByRole('button', { name: /Free weights/i }).click();
|
||||||
|
|
||||||
|
// Click Generate
|
||||||
|
await page.getByRole('button', { name: 'Generate' }).click();
|
||||||
|
|
||||||
|
// Verify Preview
|
||||||
|
// Wait for preview to appear (mock response)
|
||||||
|
await expect(page.getByText('Generated Plan')).toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(page.getByText('Mock Push-ups')).toBeVisible();
|
||||||
|
await expect(page.getByText('Mock Weighted Pull-ups')).toBeVisible();
|
||||||
|
|
||||||
|
// Icons check (weighted vs bodyweight) - optional visual check
|
||||||
|
|
||||||
|
// Click Save Plan
|
||||||
|
await page.getByRole('button', { name: 'Save Plan' }).click();
|
||||||
|
|
||||||
|
// Verify Saved
|
||||||
|
// Should close sheet and show plan list
|
||||||
|
await expect(page.getByText('AI Advanced Plan')).toBeVisible();
|
||||||
|
await expect(page.getByText('Generated High Intensity Plan')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loginAndSetup(page: any, createUniqueUser: any) {
|
||||||
|
const user = await createUniqueUser();
|
||||||
|
await page.goto('/');
|
||||||
|
await page.getByLabel('Email').fill(user.email);
|
||||||
|
await page.getByLabel('Password').fill(user.password);
|
||||||
|
await page.getByRole('button', { name: 'Login' }).click();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const heading = page.getByRole('heading', { name: /Change Password/i });
|
||||||
|
const dashboard = page.getByText('Free Workout');
|
||||||
|
await expect(heading.or(dashboard)).toBeVisible({ timeout: 5000 });
|
||||||
|
if (await heading.isVisible()) {
|
||||||
|
await page.getByLabel('New Password').fill('StrongNewPass123!');
|
||||||
|
await page.getByRole('button', { name: /Save|Change/i }).click();
|
||||||
|
await expect(dashboard).toBeVisible();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Login might already be done or dashboard loaded fast
|
||||||
|
}
|
||||||
|
return user;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user