AI Plan Generation. Clear button sets focus
This commit is contained in:
Binary file not shown.
BIN
server/test.db
BIN
server/test.db
Binary file not shown.
@@ -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`
|
||||||
|
|||||||
@@ -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**
|
||||||
|
|||||||
@@ -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'}`}
|
||||||
|
|||||||
@@ -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 */}
|
||||||
<button
|
<div className="absolute bottom-6 right-6 z-20">
|
||||||
onClick={handleCreateNew}
|
{/* Menu Options - shown when expanded */}
|
||||||
aria-label="Create Plan"
|
{fabMenuOpen && (
|
||||||
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"
|
<div className="absolute bottom-16 right-0 flex flex-col gap-2 items-end animate-in slide-in-from-bottom-2 duration-200">
|
||||||
|
<button
|
||||||
|
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"
|
||||||
|
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} />
|
||||||
|
</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"
|
||||||
>
|
>
|
||||||
<Plus size={28} />
|
<div className="space-y-6">
|
||||||
</button>
|
{/* 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 && (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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: 'Прогресс',
|
||||||
|
|||||||
Reference in New Issue
Block a user