AI Plan Generation. Clear button sets focus

This commit is contained in:
AG
2025-12-15 22:46:04 +02:00
parent 854eda98d2
commit c275804fbc
9 changed files with 542 additions and 9 deletions

Binary file not shown.

Binary file not shown.

View File

@@ -333,6 +333,38 @@ Comprehensive test plan for the GymFlow web application, covering authentication
**Expected Results:** **Expected Results:**
- All exercises are created successfully with their respective types. - All exercises are created successfully with their respective types.
#### 2.14. A. Workout Plans - Create Plan with AI
**File:** `tests/workout-management.spec.ts`
**Steps:**
1. Log in as a regular user.
2. Navigate to the 'Plans' section.
3. Click the '+' FAB button.
4. Select 'With AI' option.
5. In the AI Side Sheet, enter a prompt (e.g., 'Create a short leg workout with lunges').
6. Click 'Generate'.
7. Wait for the AI response.
**Expected Results:**
- A new plan is created and appears in the plans list.
- If 'Lunges' did not exist in the user's exercise library, it is created automatically.
- The plan contains the exercises described in the prompt.
#### 2.15. B. Tracker - Empty State AI Prompt
**File:** `tests/workout-management.spec.ts`
**Steps:**
1. Log in as a regular user with no existing plans.
2. Navigate to the 'Tracker' section (Idle View).
3. Verify the placeholder message "No workout plans yet." is displayed.
4. Click the "Ask your AI coach to create one" link.
**Expected Results:**
- User is navigated to the Plans view.
- The AI Side Sheet is automatically opened.
### 3. III. Workout Tracking ### 3. III. Workout Tracking
**Seed:** `tests/workout-tracking.spec.ts` **Seed:** `tests/workout-tracking.spec.ts`

View File

@@ -64,6 +64,23 @@ Users can structure their training via Plans.
* **Logic**: Supports reordering capabilities via drag-and-drop in UI. * **Logic**: Supports reordering capabilities via drag-and-drop in UI.
* **3.2.2 Plan Deletion** * **3.2.2 Plan Deletion**
* Standard soft or hard delete (Cascades to PlanExercises). * Standard soft or hard delete (Cascades to PlanExercises).
* **3.2.3 AI Plan Creation**
* **Trigger**: "Create with AI" option in Plans FAB Menu, or "Ask your AI coach" link from Tracker (when no plans exist).
* **UI Flow**:
* Opens a dedicated Side Sheet in the Plans view.
* User enters a text prompt describing desired workout (e.g., "Create a 20-minute HIIT workout").
* "Generate" button initiates AI call.
* **AI Logic**:
* System sends prompt to AI service (`geminiService`).
* AI returns a structured JSON object containing: `name`, `description`, and `exercises` array.
* Each exercise object contains: `name`, `isWeighted` (boolean), `restTimeSeconds` (number).
* For **new exercises** (not in user's library), AI also provides: `type` ('reps' or 'time'), `unilateral` (boolean).
* **Auto-Creation of Exercises**:
* System parses AI response.
* For each exercise in the response, checks if it exists in the user's exercise library by name.
* If not found, creates a new `Exercise` record with AI-provided attributes (type, unilateral flag) via `saveExercise`.
* Links the new/existing exercise ID to the plan step.
* **Result**: Saves the generated `WorkoutPlan` to DB and displays it in the Plans list.
### 3.3. Exercise Library ### 3.3. Exercise Library
* **3.3.1 Exercise Types** * **3.3.1 Exercise Types**

View File

@@ -26,6 +26,7 @@ const FilledInput: React.FC<FilledInputProps> = ({
multiline = false, rows = 3 multiline = false, rows = 3
}) => { }) => {
const id = useId(); const id = useId();
const inputRef = React.useRef<HTMLInputElement | HTMLTextAreaElement>(null);
const handleClear = () => { const handleClear = () => {
const syntheticEvent = { const syntheticEvent = {
@@ -33,6 +34,7 @@ const FilledInput: React.FC<FilledInputProps> = ({
} as React.ChangeEvent<HTMLInputElement>; } as React.ChangeEvent<HTMLInputElement>;
onChange(syntheticEvent); onChange(syntheticEvent);
if (onClear) onClear(); if (onClear) onClear();
inputRef.current?.focus();
}; };
return ( return (
@@ -43,6 +45,7 @@ const FilledInput: React.FC<FilledInputProps> = ({
{!multiline ? ( {!multiline ? (
<input <input
ref={inputRef as React.RefObject<HTMLInputElement>}
id={id} id={id}
type={type} type={type}
step={step} step={step}
@@ -59,6 +62,7 @@ const FilledInput: React.FC<FilledInputProps> = ({
/> />
) : ( ) : (
<textarea <textarea
ref={inputRef as React.RefObject<HTMLTextAreaElement>}
id={id} id={id}
rows={rows} rows={rows}
className={`w-full pt-6 pb-2 pl-4 bg-transparent text-body-lg text-on-surface focus:outline-none placeholder-transparent resize-none ${rightElement ? 'pr-20' : 'pr-10'}`} className={`w-full pt-6 pb-2 pl-4 bg-transparent text-body-lg text-on-surface focus:outline-none placeholder-transparent resize-none ${rightElement ? 'pr-20' : 'pr-10'}`}

View File

@@ -1,5 +1,6 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Plus, Trash2, PlayCircle, Dumbbell, Save, X, List, ArrowUp, Pencil, User, Flame, Timer as TimerIcon, Ruler, Footprints, Percent, CheckCircle, GripVertical } from 'lucide-react'; import { useSearchParams } from 'react-router-dom';
import { Plus, Trash2, PlayCircle, Dumbbell, Save, X, List, ArrowUp, Pencil, User, Flame, Timer as TimerIcon, Ruler, Footprints, Percent, CheckCircle, GripVertical, Bot, Loader2 } from 'lucide-react';
import { import {
DndContext, DndContext,
closestCenter, closestCenter,
@@ -35,6 +36,7 @@ import { Modal } from './ui/Modal';
import { SideSheet } from './ui/SideSheet'; import { SideSheet } from './ui/SideSheet';
import { Checkbox } from './ui/Checkbox'; import { Checkbox } from './ui/Checkbox';
import ExerciseModal from './ExerciseModal'; import ExerciseModal from './ExerciseModal';
import { generateWorkoutPlan } from '../services/geminiService';
interface PlansProps { interface PlansProps {
lang: Language; lang: Language;
@@ -215,6 +217,42 @@ const Plans: React.FC<PlansProps> = ({ lang }) => {
// Preparation Modal State // Preparation Modal State
const [showPlanPrep, setShowPlanPrep] = useState<WorkoutPlan | null>(null); const [showPlanPrep, setShowPlanPrep] = useState<WorkoutPlan | null>(null);
// FAB Menu State
const [fabMenuOpen, setFabMenuOpen] = useState(false);
// AI Plan Creation State
const [showAISheet, setShowAISheet] = useState(false);
const [aiPrompt, setAIPrompt] = useState('');
const [aiLoading, setAILoading] = useState(false);
const [aiError, setAIError] = useState<string | null>(null);
const [aiDuration, setAIDuration] = useState(60); // Default 1 hour in minutes
const [aiEquipment, setAIEquipment] = useState<'none' | 'essentials' | 'free_weights' | 'full_gym'>('full_gym');
const [generatedPlanPreview, setGeneratedPlanPreview] = useState<{
name: string;
description: string;
exercises: Array<{
name: string;
isWeighted: boolean;
restTimeSeconds: number;
type?: string;
unilateral?: boolean;
}>;
} | null>(null);
// URL params handling
const [searchParams, setSearchParams] = useSearchParams();
useEffect(() => {
if (searchParams.get('create') === 'true') {
handleCreateNew();
setSearchParams({});
}
if (searchParams.get('aiPrompt') === 'true') {
setShowAISheet(true);
setSearchParams({});
}
}, [searchParams, setSearchParams]);
const handleStart = (plan: WorkoutPlan) => { const handleStart = (plan: WorkoutPlan) => {
if (plan.description && plan.description.trim().length > 0) { if (plan.description && plan.description.trim().length > 0) {
setShowPlanPrep(plan); setShowPlanPrep(plan);
@@ -367,6 +405,101 @@ const Plans: React.FC<PlansProps> = ({ lang }) => {
} }
}; };
const handleGenerateAIPlan = async () => {
if (aiLoading) return;
setAILoading(true);
setAIError(null);
setGeneratedPlanPreview(null);
try {
const availableNames = availableExercises.map(e => e.name);
const prompt = aiPrompt.trim()
? aiPrompt
: (lang === 'ru' ? 'Создай план тренировки' : 'Create a workout plan');
const aiPlan = await generateWorkoutPlan(prompt, availableNames, lang, aiDuration, aiEquipment);
setGeneratedPlanPreview(aiPlan);
} catch (err: any) {
console.error('AI plan generation error:', err);
setAIError(err.message || 'Failed to generate plan');
} finally {
setAILoading(false);
}
};
const handleSaveAIPlan = async () => {
if (!generatedPlanPreview || aiLoading) return;
setAILoading(true);
setAIError(null);
try {
// Build plan steps, creating new exercises as needed
const planSteps: PlannedSet[] = [];
for (const aiEx of generatedPlanPreview.exercises) {
let existingEx = availableExercises.find(
e => e.name.toLowerCase() === aiEx.name.toLowerCase()
);
if (!existingEx) {
// Create new exercise - map AI type to ExerciseType enum
const typeMap: Record<string, ExerciseType> = {
'STRENGTH': ExerciseType.STRENGTH,
'BODYWEIGHT': ExerciseType.BODYWEIGHT,
'CARDIO': ExerciseType.CARDIO,
'STATIC': ExerciseType.STATIC,
'PLYOMETRIC': ExerciseType.PLYOMETRIC,
'HIGH_JUMP': ExerciseType.HIGH_JUMP,
'LONG_JUMP': ExerciseType.LONG_JUMP,
};
const mappedType = typeMap[aiEx.type?.toUpperCase() || 'STRENGTH'] || ExerciseType.STRENGTH;
const newEx: ExerciseDef = {
id: generateId(),
name: aiEx.name,
type: mappedType,
isUnilateral: aiEx.unilateral || false,
bodyWeightPercentage: undefined,
};
await saveExercise(userId, newEx);
existingEx = newEx;
// Add to local list
setAvailableExercises(prev => [...prev, newEx]);
}
planSteps.push({
id: generateId(),
exerciseId: existingEx.id,
exerciseName: existingEx.name,
exerciseType: existingEx.type,
isWeighted: aiEx.isWeighted || false,
restTimeSeconds: aiEx.restTimeSeconds || 120,
});
}
// Save the plan
const newPlan: WorkoutPlan = {
id: generateId(),
name: generatedPlanPreview.name,
description: generatedPlanPreview.description,
steps: planSteps,
};
await savePlan(newPlan);
// Reset state and close
setAIPrompt('');
setAIDuration(60);
setAIEquipment('full_gym');
setGeneratedPlanPreview(null);
setShowAISheet(false);
setFabMenuOpen(false);
} catch (err: any) {
console.error('AI plan save error:', err);
setAIError(err.message || 'Failed to save plan');
} finally {
setAILoading(false);
}
};
if (isEditing) { if (isEditing) {
return ( return (
<div className="h-full flex flex-col bg-surface"> <div className="h-full flex flex-col bg-surface">
@@ -582,14 +715,194 @@ const Plans: React.FC<PlansProps> = ({ lang }) => {
)} )}
</div> </div>
{/* FAB */} {/* FAB Menu */}
<div className="absolute bottom-6 right-6 z-20">
{/* Menu Options - shown when expanded */}
{fabMenuOpen && (
<div className="absolute bottom-16 right-0 flex flex-col gap-2 items-end animate-in slide-in-from-bottom-2 duration-200">
<button <button
onClick={handleCreateNew} onClick={() => { setFabMenuOpen(false); handleCreateNew(); }}
className="flex items-center gap-2 px-4 py-2 bg-surface-container-high text-on-surface rounded-full shadow-elevation-2 hover:bg-surface-container-highest transition-colors"
>
<Pencil size={18} />
<span className="font-medium text-sm whitespace-nowrap">{t('create_manually', lang)}</span>
</button>
<button
onClick={() => { setFabMenuOpen(false); setShowAISheet(true); }}
className="flex items-center gap-2 px-4 py-2 bg-secondary-container text-on-secondary-container rounded-full shadow-elevation-2 hover:opacity-90 transition-opacity"
>
<Bot size={18} />
<span className="font-medium text-sm whitespace-nowrap">{t('create_with_ai', lang)}</span>
</button>
</div>
)}
{/* Main FAB */}
<button
onClick={() => setFabMenuOpen(!fabMenuOpen)}
aria-label="Create Plan" aria-label="Create Plan"
className="absolute bottom-6 right-6 w-14 h-14 bg-primary-container text-on-primary-container rounded-[16px] shadow-elevation-3 flex items-center justify-center hover:bg-primary hover:text-on-primary transition-colors z-20" className={`w-14 h-14 bg-primary-container text-on-primary-container rounded-[16px] shadow-elevation-3 flex items-center justify-center hover:bg-primary hover:text-on-primary transition-all ${fabMenuOpen ? 'rotate-45' : ''}`}
> >
<Plus size={28} /> <Plus size={28} />
</button> </button>
</div>
{/* Backdrop for FAB Menu */}
{fabMenuOpen && (
<div
className="fixed inset-0 z-10"
onClick={() => setFabMenuOpen(false)}
/>
)}
{/* AI Plan Creation Side Sheet */}
<SideSheet
isOpen={showAISheet}
onClose={() => {
setShowAISheet(false);
setAIPrompt('');
setAIError(null);
setGeneratedPlanPreview(null);
setAIDuration(60);
setAIEquipment('full_gym');
}}
title={t('ai_plan_prompt_title', lang)}
width="lg"
>
<div className="space-y-6">
{/* Duration Slider */}
<div className="space-y-2">
<label className="text-sm font-medium text-on-surface flex items-center justify-between">
<span>{t('duration', lang)}</span>
<span className="text-primary font-bold">
{aiDuration >= 120 ? t('duration_hours_plus', lang) : `${aiDuration} ${t('duration_minutes', lang)}`}
</span>
</label>
<input
type="range"
min="5"
max="120"
step="5"
value={aiDuration}
onChange={(e) => setAIDuration(Number(e.target.value))}
className="w-full h-2 bg-surface-container-highest rounded-lg appearance-none cursor-pointer accent-primary"
disabled={aiLoading}
/>
<div className="flex justify-between text-xs text-on-surface-variant">
<span>5 {t('duration_minutes', lang)}</span>
<span>{t('duration_hours_plus', lang)}</span>
</div>
</div>
{/* Equipment Selector */}
<div className="space-y-2">
<label className="text-sm font-medium text-on-surface">{t('equipment', lang)}</label>
<div className="grid grid-cols-2 gap-2">
{(['none', 'essentials', 'free_weights', 'full_gym'] as const).map((level) => (
<button
key={level}
onClick={() => setAIEquipment(level)}
disabled={aiLoading}
className={`p-3 rounded-xl text-sm font-medium transition-all ${aiEquipment === level
? 'bg-primary text-on-primary'
: 'bg-surface-container-high text-on-surface hover:bg-surface-container-highest'
}`}
>
{t(`equipment_${level}` as any, lang)}
</button>
))}
</div>
</div>
{/* Additional Requirements */}
<div className="space-y-2">
<label className="text-sm font-medium text-on-surface">
{lang === 'ru' ? 'Дополнительные требования' : 'Additional requirements'}
</label>
<textarea
className="w-full h-20 p-3 bg-surface-container-high rounded-xl text-on-surface resize-none focus:outline-none focus:ring-2 focus:ring-primary text-sm"
placeholder={t('ai_plan_prompt_placeholder', lang)}
value={aiPrompt}
onChange={(e) => setAIPrompt(e.target.value)}
disabled={aiLoading}
/>
</div>
{/* Error Display */}
{aiError && (
<div className="p-3 bg-error-container text-on-error-container rounded-lg text-sm">
{aiError}
</div>
)}
{/* Generated Plan Preview */}
{generatedPlanPreview && (
<div className="space-y-3 p-4 bg-surface-container rounded-xl border border-outline-variant">
<div className="text-xs font-bold text-primary">{t('ai_generated_plan', lang)}</div>
<h3 className="text-lg font-medium text-on-surface">{generatedPlanPreview.name}</h3>
{generatedPlanPreview.description && (
<p className="text-sm text-on-surface-variant">{generatedPlanPreview.description}</p>
)}
<div className="space-y-1">
{generatedPlanPreview.exercises.map((ex, idx) => (
<div key={idx} className="flex items-center gap-2 text-sm py-1 border-b border-outline-variant/30 last:border-0">
<span className="w-6 h-6 flex items-center justify-center text-xs font-bold bg-primary-container text-on-primary-container rounded-full">{idx + 1}</span>
<span className="text-on-surface">{ex.name}</span>
{ex.isWeighted && <Dumbbell size={14} className="text-on-surface-variant" />}
</div>
))}
</div>
</div>
)}
{/* Action Buttons */}
<div className="flex justify-end gap-2 pt-2">
{!generatedPlanPreview ? (
<>
<Button
onClick={() => {
setShowAISheet(false);
setAIPrompt('');
setAIError(null);
setGeneratedPlanPreview(null);
}}
variant="ghost"
disabled={aiLoading}
>
{t('cancel', lang)}
</Button>
<Button
onClick={handleGenerateAIPlan}
disabled={aiLoading}
className="flex items-center gap-2"
>
{aiLoading && <Loader2 size={16} className="animate-spin" />}
{t('generate', lang)}
</Button>
</>
) : (
<>
<Button
onClick={handleGenerateAIPlan}
variant="ghost"
disabled={aiLoading}
className="flex items-center gap-2"
>
{aiLoading && <Loader2 size={16} className="animate-spin" />}
{t('generate', lang)}
</Button>
<Button
onClick={handleSaveAIPlan}
disabled={aiLoading}
className="flex items-center gap-2"
>
{aiLoading && <Loader2 size={16} className="animate-spin" />}
{t('save_plan', lang)}
</Button>
</>
)}
</div>
</div>
</SideSheet>
{/* Preparation Modal */} {/* Preparation Modal */}
{showPlanPrep && ( {showPlanPrep && (

View File

@@ -124,7 +124,7 @@ const IdleView: React.FC<IdleViewProps> = ({ tracker, lang }) => {
</button> </button>
</div> </div>
{plans.length > 0 && ( {plans.length > 0 ? (
<div className="w-full max-w-md mt-8"> <div className="w-full max-w-md mt-8">
<h3 className="text-sm text-on-surface-variant font-medium px-4 mb-3">{t('or_choose_plan', lang)}</h3> <h3 className="text-sm text-on-surface-variant font-medium px-4 mb-3">{t('or_choose_plan', lang)}</h3>
<div className="grid grid-cols-1 gap-3"> <div className="grid grid-cols-1 gap-3">
@@ -145,6 +145,24 @@ const IdleView: React.FC<IdleViewProps> = ({ tracker, lang }) => {
))} ))}
</div> </div>
</div> </div>
) : (
<div className="w-full max-w-md mt-8 text-center p-6 bg-surface-container rounded-2xl border border-outline-variant/20">
<p className="text-on-surface-variant mb-4">{t('no_plans_yet', lang)}</p>
<div className="flex flex-col gap-2">
<a
href="/plans?aiPrompt=true"
className="text-primary font-medium hover:underline"
>
{t('ask_ai_to_create', lang)}
</a>
<a
href="/plans?create=true"
className="text-primary font-medium hover:underline"
>
{t('create_manually', lang)}
</a>
</div>
</div>
)} )}
</div> </div>

View File

@@ -1,6 +1,7 @@
import { WorkoutSession, UserProfile, WorkoutPlan } from '../types'; import { WorkoutSession, UserProfile, WorkoutPlan } from '../types';
import { api } from './api'; import { api } from './api';
import { generateId } from '../utils/uuid'; import { generateId } from '../utils/uuid';
import { t } from './i18n';
interface ApiResponse<T> { interface ApiResponse<T> {
success: boolean; success: boolean;
@@ -131,3 +132,107 @@ export const createFitnessChat = (
} }
}; };
}; };
interface AIPlanExercise {
name: string;
isWeighted: boolean;
restTimeSeconds: number;
// For new exercises:
type?: 'reps' | 'time';
unilateral?: boolean;
}
interface AIPlanResponse {
name: string;
description: string;
exercises: AIPlanExercise[];
}
// Removed duplicate import
// ... (other code)
export const generateWorkoutPlan = async (
userPrompt: string,
availableExercises: string[],
lang: 'en' | 'ru' = 'en',
durationMinutes: number = 60,
equipment: 'none' | 'essentials' | 'free_weights' | 'full_gym' = 'full_gym'
): Promise<AIPlanResponse> => {
const equipmentDescription = t(`ai_equipment_desc_${equipment}` as any, lang);
const durationText = durationMinutes >= 120
? (lang === 'ru' ? '2+ часа' : '2+ hours')
: `${durationMinutes} ${lang === 'ru' ? 'минут' : 'minutes'}`;
const systemInstruction = lang === 'ru'
? `Ты — генератор планов тренировок. Верни ТОЛЬКО JSON-объект (без дополнительного текста).
ПАРАМЕТРЫ ТРЕНИРОВКИ:
- Длительность: ${durationText}
- Оборудование: ${equipmentDescription}
ВАЖНО: Каждый элемент массива exercises — это ОДИН подход. Если нужно 3 подхода одного упражнения, оно должно встретиться в списке 3 раза подряд.
Формат ответа:
{
"name": "Название плана",
"description": "Описание/инструкции по подготовке",
"exercises": [
{ "name": "Название упражнения", "isWeighted": false, "restTimeSeconds": 60 }
]
}
Если упражнение ОТСУТСТВУЕТ в списке доступных, добавь поля:
"type": один из STRENGTH, BODYWEIGHT, CARDIO, STATIC, PLYOMETRIC,
"unilateral": true или false
Доступные упражнения: ${JSON.stringify(availableExercises)}
`
: `You are a workout plan generator. Return ONLY a JSON object (no extra text).
WORKOUT PARAMETERS:
- Duration: ${durationText}
- 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.
Response format:
{
"name": "Plan Name",
"description": "Description/preparation instructions",
"exercises": [
{ "name": "Exercise Name", "isWeighted": false, "restTimeSeconds": 60 }
]
}
If an exercise is NOT in the available list, also add:
"type": one of STRENGTH, BODYWEIGHT, CARDIO, STATIC, PLYOMETRIC,
"unilateral": true or false
Available exercises: ${JSON.stringify(availableExercises)}
`;
const res = await api.post<ApiResponse<{ response: string }>>('/ai/chat', {
systemInstruction,
userMessage: userPrompt,
sessionId: generateId()
});
const responseText = res.data.response;
// Try to parse JSON from the response
// The AI might wrap it in ```json ... ``` or return raw JSON
let jsonStr = responseText;
const jsonMatch = responseText.match(/```(?:json)?\s*([\s\S]*?)```/);
if (jsonMatch) {
jsonStr = jsonMatch[1];
}
try {
const parsed = JSON.parse(jsonStr.trim()) as AIPlanResponse;
return parsed;
} catch (e) {
console.error('Failed to parse AI plan response:', responseText);
throw new Error('AI returned invalid plan format');
}
};

View File

@@ -121,6 +121,28 @@ const translations = {
weighted: 'Weighted', weighted: 'Weighted',
add_exercise: 'Add Exercise', add_exercise: 'Add Exercise',
my_plans: 'My Plans', my_plans: 'My Plans',
no_plans_yet: 'No workout plans yet.',
ask_ai_to_create: 'Ask your AI coach to create one',
create_manually: 'Create one manually',
create_with_ai: 'With AI',
ai_plan_prompt_title: 'Create Plan with AI',
ai_plan_prompt_placeholder: 'Any specific requirements? (optional)',
generate: 'Generate',
save_plan: 'Save Plan',
duration: 'Duration',
duration_minutes: 'min',
duration_hours_plus: '2+ hours',
equipment: 'Equipment',
equipment_none: 'No equipment',
equipment_essentials: 'Street workout essentials',
equipment_free_weights: 'Free weights',
equipment_full_gym: 'Complete gym',
ai_generated_plan: 'Generated Plan',
regenerate: 'Regenerate',
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_free_weights: 'Free weights - dumbbells, kettlebells, barbells',
ai_equipment_desc_full_gym: 'Complete gym - all machines and equipment available',
// Stats // Stats
progress: 'Progress', progress: 'Progress',
@@ -305,6 +327,28 @@ const translations = {
weighted: 'С отягощением', weighted: 'С отягощением',
add_exercise: 'Добавить упражнение', add_exercise: 'Добавить упражнение',
my_plans: 'Мои планы', my_plans: 'Мои планы',
no_plans_yet: 'Планов тренировок пока нет.',
ask_ai_to_create: 'Попросите AI-тренера создать',
create_manually: 'Создать вручную',
create_with_ai: 'С помощью AI',
ai_plan_prompt_title: 'Создать план с AI',
ai_plan_prompt_placeholder: 'Дополнительные требования? (опционально)',
generate: 'Сгенерировать',
save_plan: 'Сохранить план',
duration: 'Длительность',
duration_minutes: 'мин',
duration_hours_plus: '2+ часа',
equipment: 'Оборудование',
equipment_none: 'Без оборудования',
equipment_essentials: 'Street workout (турники, брусья)',
equipment_free_weights: 'Свободные веса',
equipment_full_gym: 'Полный зал',
ai_generated_plan: 'Сгенерированный план',
regenerate: 'Перегенерировать',
ai_equipment_desc_none: 'Без оборудования - только упражнения с собственным весом',
ai_equipment_desc_essentials: 'Street workout - турник, брусья, кольца',
ai_equipment_desc_free_weights: 'Свободные веса - гантели, гири, штанга',
ai_equipment_desc_full_gym: 'Полный зал - все тренажеры и оборудование',
// Stats // Stats
progress: 'Прогресс', progress: 'Прогресс',