Улучшения UI прогрессбаров и карточек
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m9s

This commit is contained in:
poignatov
2026-01-20 18:04:18 +03:00
parent f884bd3339
commit efded0bcd2
5 changed files with 140 additions and 154 deletions

View File

@@ -1 +1 @@
3.19.0 3.20.0

View File

@@ -1,12 +1,12 @@
{ {
"name": "play-life-web", "name": "play-life-web",
"version": "3.8.9", "version": "3.19.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "play-life-web", "name": "play-life-web",
"version": "3.8.9", "version": "3.19.0",
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
@@ -14,6 +14,7 @@
"chart.js": "^4.4.0", "chart.js": "^4.4.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-chartjs-2": "^5.2.0", "react-chartjs-2": "^5.2.0",
"react-circular-progressbar": "^2.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-easy-crop": "^5.5.6" "react-easy-crop": "^5.5.6"
}, },
@@ -5913,6 +5914,15 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
} }
}, },
"node_modules/react-circular-progressbar": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/react-circular-progressbar/-/react-circular-progressbar-2.2.0.tgz",
"integrity": "sha512-cgyqEHOzB0nWMZjKfWN3MfSa1LV3OatcDjPz68lchXQUEiBD5O1WsAtoVK4/DSL0B4USR//cTdok4zCBkq8X5g==",
"license": "MIT",
"peerDependencies": {
"react": ">=0.14.0"
}
},
"node_modules/react-dom": { "node_modules/react-dom": {
"version": "18.3.1", "version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",

View File

@@ -1,6 +1,6 @@
{ {
"name": "play-life-web", "name": "play-life-web",
"version": "3.19.0", "version": "3.20.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -14,6 +14,7 @@
"chart.js": "^4.4.0", "chart.js": "^4.4.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-chartjs-2": "^5.2.0", "react-chartjs-2": "^5.2.0",
"react-circular-progressbar": "^2.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-easy-crop": "^5.5.6" "react-easy-crop": "^5.5.6"
}, },

View File

@@ -1,133 +1,108 @@
import ProjectProgressBar from './ProjectProgressBar' import ProjectProgressBar from './ProjectProgressBar'
import LoadingError from './LoadingError' import LoadingError from './LoadingError'
import { getAllProjectsSorted, getProjectColor } from '../utils/projectUtils' import { getAllProjectsSorted, getProjectColor } from '../utils/projectUtils'
import { CircularProgressbar, buildStyles } from 'react-circular-progressbar'
import 'react-circular-progressbar/dist/styles.css'
// Компонент круглого прогрессбара // Компонент круглого прогрессбара с использованием react-circular-progressbar
function CircularProgressBar({ progress, size = 120, strokeWidth = 8, showCheckmark = true }) { function CircularProgressBar({ progress, size = 120, strokeWidth = 8, showCheckmark = true, extraProgress = null, maxProgress = 100, textSize = 'large', displayProgress = null, textPosition = 'default', projectColor = null }) {
// Нормализуем прогресс для визуализации (0-100%)
const normalizedProgress = Math.min(Math.max(progress || 0, 0), 100) const normalizedProgress = Math.min(Math.max(progress || 0, 0), 100)
const radius = (size - strokeWidth) / 2
const circumference = radius * 2 * Math.PI // Если есть extra progress, вычисляем визуальный прогресс для overlay
const strokeDasharray = `${(normalizedProgress / 100) * circumference} ${circumference}` const extraVisual = extraProgress !== null && extraProgress > 0
? Math.min((extraProgress / maxProgress) * 100, 100)
: 0
// Определяем, достигнут ли 100% или выше
const isComplete = (displayProgress !== null ? displayProgress : progress) >= 100
// Определяем градиент ID: зелёный если >= 100%, иначе по наличию extra progress
const gradientId = isComplete ? 'success-gradient' : (extraVisual > 0 ? 'project-gradient' : 'overall-gradient')
const extraGradientId = 'project-extra-gradient'
// Определяем класс размера текста
const textSizeClass = textSize === 'large' ? 'text-4xl' : textSize === 'small' ? 'text-base' : 'text-lg'
// Используем displayProgress если передан (может быть больше 100%), иначе progress
const progressToDisplay = displayProgress !== null ? displayProgress : progress
return ( return (
<div className="relative flex items-center justify-center" style={{ width: size, height: size }}> <div className="relative" style={{ width: size, height: size }}>
<svg className="transform -rotate-90" viewBox={`0 0 ${size} ${size}`}> <CircularProgressbar
{/* Фоновая окружность */} value={normalizedProgress}
<circle strokeWidth={strokeWidth / size * 100}
cx={size / 2} styles={buildStyles({
cy={size / 2} // Цвета
r={radius} pathColor: `url(#${gradientId})`,
stroke="currentColor" trailColor: '#e5e7eb',
strokeWidth={strokeWidth} // Анимация
fill="none" pathTransitionDuration: 1,
className="text-gray-200" // Размер текста (убираем встроенный)
textSize: '0px',
// Поворот, чтобы пустая часть была снизу
rotation: 0.625,
strokeLinecap: 'round',
})}
// Создаем неполный круг (270 градусов)
circleRatio={0.75}
/>
{/* Extra progress overlay (если есть) */}
{extraVisual > 0 && (
<CircularProgressbar
value={extraVisual}
strokeWidth={strokeWidth / size * 100}
styles={buildStyles({
rotation: 0.625,
strokeLinecap: 'round',
textSize: '0px',
pathColor: `url(#${extraGradientId})`,
trailColor: 'transparent',
pathTransitionDuration: 1,
})}
circleRatio={0.75}
className="absolute inset-0"
/> />
{/* Прогресс окружность */} )}
<circle
cx={size / 2} {/* Иконка статистики в центре */}
cy={size / 2} <div className="absolute inset-0 flex items-center justify-center">
r={radius} <svg
stroke="url(#overall-gradient)" width={size * 0.3}
strokeWidth={strokeWidth} height={size * 0.3}
fill="none" viewBox="0 0 24 24"
strokeDasharray={strokeDasharray} fill="none"
strokeLinecap="round" stroke="currentColor"
className="transition-all duration-1000 ease-out" strokeWidth="2"
/> strokeLinecap="round"
{/* Галочка при 100% */} strokeLinejoin="round"
{showCheckmark && progress >= 100 && ( style={{
<g className="transform rotate-90" style={{ transformOrigin: `${size / 2}px ${size / 2}px` }}> color: isComplete ? '#10b981' : '#4f46e5'
<path }}
d={`M ${size / 2 - 12} ${size / 2} L ${size / 2 - 4} ${size / 2 + 8} L ${size / 2 + 12} ${size / 2 - 8}`} >
stroke="url(#overall-gradient)" <line x1="18" y1="20" x2="18" y2="10"></line>
strokeWidth="3" <line x1="12" y1="20" x2="12" y2="4"></line>
strokeLinecap="round" <line x1="6" y1="20" x2="6" y2="14"></line>
strokeLinejoin="round" </svg>
fill="none" </div>
/>
</g> {/* Кастомный текст снизу */}
)} <div className={`absolute inset-0 flex justify-center items-end ${textPosition === 'lower' ? '' : 'pb-2'}`} style={textPosition === 'lower' ? { bottom: '0.125rem' } : {}}>
<div className="text-center">
<div className={`${textSizeClass} font-bold`} style={{ color: isComplete ? '#10b981' : '#4f46e5' }}>
{progressToDisplay !== null && progressToDisplay !== undefined ? `${progressToDisplay.toFixed(0)}%` : 'N/A'}
</div>
</div>
</div>
{/* Градиенты для SVG */}
<svg width="0" height="0">
<defs> <defs>
<linearGradient id="overall-gradient" x1="0%" y1="0%" x2="100%" y2="0%"> <linearGradient id="overall-gradient" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#4f46e5" /> <stop offset="0%" stopColor="#4f46e5" />
<stop offset="100%" stopColor="#9333ea" /> <stop offset="100%" stopColor="#9333ea" />
</linearGradient> </linearGradient>
</defs>
</svg>
{/* Процент в центре */}
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-center">
<div className="text-5xl font-bold bg-gradient-to-r from-indigo-600 to-purple-600 bg-clip-text text-transparent">
{progress !== null && progress !== undefined ? `${progress.toFixed(0)}%` : 'N/A'}
</div>
</div>
</div>
</div>
)
}
// Специальный компонент круглого прогрессбара с поддержкой экстра прогресса
function ProjectCircularProgressBar({ progress, extraProgress, size = 120, strokeWidth = 8, showCheckmark = true, percentage }) {
const normalizedProgress = Math.min(Math.max(progress || 0, 0), 100)
const normalizedExtraProgress = Math.min(Math.max(extraProgress || 0, 0), 100)
const radius = (size - strokeWidth) / 2
const circumference = radius * 2 * Math.PI
const strokeDasharray = `${(normalizedProgress / 100) * circumference} ${circumference}`
const extraStrokeDasharray = normalizedExtraProgress > 0 ? `${(normalizedExtraProgress / 100) * circumference} ${circumference}` : ''
return (
<div className="relative flex items-center justify-center" style={{ width: size, height: size }}>
<svg className="transform -rotate-90" viewBox={`0 0 ${size} ${size}`}>
{/* Фоновая окружность */}
<circle
cx={size / 2}
cy={size / 2}
r={radius}
stroke="currentColor"
strokeWidth={strokeWidth}
fill="none"
className="text-gray-200"
/>
{/* Базовый прогресс окружность */}
<circle
cx={size / 2}
cy={size / 2}
r={radius}
stroke="url(#project-gradient)"
strokeWidth={strokeWidth}
fill="none"
strokeDasharray={strokeDasharray}
strokeLinecap="round"
className="transition-all duration-1000 ease-out"
/>
{/* Экстра прогресс окружность (другой цвет) */}
{normalizedExtraProgress > 0 && (
<circle
cx={size / 2}
cy={size / 2}
r={radius}
stroke="url(#project-extra-gradient)"
strokeWidth={strokeWidth}
fill="none"
strokeDasharray={extraStrokeDasharray}
strokeLinecap="round"
className="transition-all duration-1000 ease-out"
/>
)}
{/* Галочка при 100% */}
{showCheckmark && progress >= 100 && (
<g className="transform rotate-90" style={{ transformOrigin: `${size / 2}px ${size / 2}px` }}>
<path
d={`M ${size / 2 - 12} ${size / 2} L ${size / 2 - 4} ${size / 2 + 8} L ${size / 2 + 12} ${size / 2 - 8}`}
stroke="url(#project-gradient)"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
</g>
)}
<defs>
<linearGradient id="project-gradient" x1="0%" y1="0%" x2="100%" y2="0%"> <linearGradient id="project-gradient" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#4f46e5" /> <stop offset="0%" stopColor="#4f46e5" />
<stop offset="100%" stopColor="#9333ea" /> <stop offset="100%" stopColor="#9333ea" />
@@ -136,18 +111,12 @@ function ProjectCircularProgressBar({ progress, extraProgress, size = 120, strok
<stop offset="0%" stopColor="#f59e0b" /> <stop offset="0%" stopColor="#f59e0b" />
<stop offset="100%" stopColor="#d97706" /> <stop offset="100%" stopColor="#d97706" />
</linearGradient> </linearGradient>
<linearGradient id="success-gradient" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#10b981" />
<stop offset="100%" stopColor="#059669" />
</linearGradient>
</defs> </defs>
</svg> </svg>
{/* Процент в центре */}
{percentage && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-center">
<div className="text-xl font-bold bg-gradient-to-r from-indigo-600 to-purple-600 bg-clip-text text-transparent">
{percentage}%
</div>
</div>
</div>
)}
</div> </div>
) )
} }
@@ -212,10 +181,14 @@ function ProjectCard({ project, projectColor, onProjectClick }) {
})() })()
// Для визуального отображения: 100% прогрессбара = максимум для данного приоритета // Для визуального отображения: 100% прогрессбара = максимум для данного приоритета
const visualProgress = (goalProgress / maxProgressForPriority) * 100 // visualProgress показывает процент заполнения прогрессбара (0-100%), где 100% = maxProgressForPriority
const baseProgress = Math.min(goalProgress, 100) // Базовая часть (0-100%) const visualProgress = Math.min((goalProgress / maxProgressForPriority) * 100, 100)
const extraProgress = Math.max(0, goalProgress - 100) // Экстра часть (свыше 100%)
const extraVisualProgress = (extraProgress / maxProgressForPriority) * 100 // Экстра часть в процентах от полного диапазона // Для extra overlay: если goalProgress > 100%, показываем extra часть
// Но визуально это уже учтено в visualProgress, так что extra overlay не нужен
// Однако если нужно показать, что достигнут максимум, можно использовать другой подход
const baseVisualProgress = visualProgress
const extraVisualProgress = 0 // Не используем extra overlay, так как visualProgress уже показывает весь прогресс
// Вычисляем целевую зону // Вычисляем целевую зону
const getTargetZone = () => { const getTargetZone = () => {
@@ -239,32 +212,33 @@ function ProjectCard({ project, projectColor, onProjectClick }) {
return ( return (
<div <div
onClick={handleClick} onClick={handleClick}
className="bg-white rounded-2xl py-3 px-3 shadow-sm hover:shadow-md transition-all duration-300 cursor-pointer border border-gray-200 hover:border-indigo-300" className="bg-white rounded-3xl py-3 px-4 shadow-sm hover:shadow-md transition-all duration-300 cursor-pointer border border-gray-200 hover:border-indigo-300"
> >
{/* Верхняя часть с названием и прогрессом */} {/* Верхняя часть с названием и прогрессом */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
{/* Левая часть - текст (название, баллы, целевая зона) */} {/* Левая часть - текст (название, баллы, целевая зона) */}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="text-base font-semibold text-gray-600 leading-normal truncate mb-1"> <div className="text-base font-semibold text-gray-600 leading-normal truncate mb-0.5">
{project_name} {project_name}
</div> </div>
<div className="text-3xl font-bold text-black leading-normal mb-1"> <div className="text-3xl font-bold text-black leading-normal mb-0.5">
{total_score?.toFixed(1) || '0.0'} {total_score?.toFixed(1) || '0.0'}
</div> </div>
<div className="text-xs text-gray-500 leading-normal"> <div className="text-xs text-gray-500 leading-normal">
Целевая зона: {getTargetZone()} Целевая зона: {getTargetZone()}
</div> </div>
</div> </div>
{/* Правая часть - круглый прогрессбар */} {/* Правая часть - круглый прогрессбар */}
<div className="flex-shrink-0 ml-3"> <div className="flex-shrink-0 ml-3">
<ProjectCircularProgressBar <CircularProgressBar
progress={visualProgress} progress={baseVisualProgress}
extraProgress={extraVisualProgress}
size={80} size={80}
strokeWidth={8} strokeWidth={8}
showCheckmark={false} textSize="small"
percentage={goalProgress.toFixed(0)} displayProgress={goalProgress}
textPosition="lower"
projectColor={projectColor}
/> />
</div> </div>
</div> </div>
@@ -446,6 +420,7 @@ function CurrentWeek({ onProjectClick, data, loading, error, onRetry, allProject
progress={displayOverallProgress} progress={displayOverallProgress}
size={180} size={180}
strokeWidth={12} strokeWidth={12}
showCheckmark={true}
/> />
{/* Подсказка при наведении */} {/* Подсказка при наведении */}
<div className="absolute inset-0 rounded-full opacity-0 hover:opacity-100 transition-opacity duration-200 bg-black bg-opacity-10 flex items-center justify-center"> <div className="absolute inset-0 rounded-full opacity-0 hover:opacity-100 transition-opacity duration-200 bg-black bg-opacity-10 flex items-center justify-center">

View File

@@ -213,7 +213,7 @@ function FullStatistics({ selectedProject, onClearSelection, data, loading, erro
</button> </button>
)} )}
<div style={{ height: '550px', paddingTop: '60px' }}> <div style={{ height: '550px', paddingTop: '100px' }}>
<Line data={chartData} options={chartOptions} /> <Line data={chartData} options={chartOptions} />
</div> </div>
<WeekProgressChart data={data} allProjectsSorted={getAllProjectsSorted(data)} currentWeekData={currentWeekData} selectedProject={selectedProject} /> <WeekProgressChart data={data} allProjectsSorted={getAllProjectsSorted(data)} currentWeekData={currentWeekData} selectedProject={selectedProject} />