4.11.0: Редактирование экрана статистики
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m1s
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m1s
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "play-life-web",
|
||||
"version": "4.10.2",
|
||||
"version": "4.11.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -550,11 +550,9 @@ function AppContent() {
|
||||
setActiveTab(tab)
|
||||
setTabParams(params)
|
||||
markTabAsLoaded(tab)
|
||||
// Если это экран full с selectedProject, восстанавливаем его
|
||||
if (tab === 'full' && params.selectedProject) {
|
||||
setSelectedProject(params.selectedProject)
|
||||
} else if (tab === 'full') {
|
||||
setSelectedProject(null)
|
||||
// Если это экран full, устанавливаем selectedProject только если он есть в params
|
||||
if (tab === 'full') {
|
||||
setSelectedProject(params.selectedProject || null)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -580,9 +578,9 @@ function AppContent() {
|
||||
}
|
||||
})
|
||||
setTabParams(params)
|
||||
// Если это экран full с selectedProject, восстанавливаем его
|
||||
if (tabFromUrl === 'full' && params.selectedProject) {
|
||||
setSelectedProject(params.selectedProject)
|
||||
// Если это экран full, устанавливаем selectedProject только если он есть в params
|
||||
if (tabFromUrl === 'full') {
|
||||
setSelectedProject(params.selectedProject || null)
|
||||
}
|
||||
} else {
|
||||
// Если в URL нет глубокого таба, значит мы вернулись на основной таб
|
||||
@@ -697,6 +695,11 @@ function AppContent() {
|
||||
setActiveTab(tab)
|
||||
if (tab === 'current') {
|
||||
setSelectedProject(null)
|
||||
} else if (tab === 'full') {
|
||||
// Если переходим на full без selectedProject в params, очищаем выбранный проект
|
||||
if (!params.selectedProject) {
|
||||
setSelectedProject(null)
|
||||
}
|
||||
}
|
||||
// Обновляем список слов при возврате из экрана добавления слов
|
||||
if (activeTab === 'add-words' && tab === 'words') {
|
||||
|
||||
@@ -1,187 +1,18 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
Filler
|
||||
} from 'chart.js'
|
||||
import { Line } from 'react-chartjs-2'
|
||||
import WeekProgressChart from './WeekProgressChart'
|
||||
import LoadingError from './LoadingError'
|
||||
import { getAllProjectsSorted, getProjectColor, sortProjectsLikeCurrentWeek } from '../utils/projectUtils'
|
||||
import { getAllProjectsSorted } from '../utils/projectUtils'
|
||||
import './Integrations.css'
|
||||
|
||||
// Экспортируем для обратной совместимости (если используется в других местах)
|
||||
export { getProjectColorByIndex } from '../utils/projectUtils'
|
||||
|
||||
const parseWeekKey = (weekKey) => {
|
||||
const [yearStr, weekStr] = weekKey.split('-W')
|
||||
return { year: Number(yearStr), week: Number(weekStr) }
|
||||
}
|
||||
|
||||
const compareWeekKeys = (a, b) => {
|
||||
const [yearA, weekA] = a.split('-W').map(Number)
|
||||
const [yearB, weekB] = b.split('-W').map(Number)
|
||||
|
||||
if (yearA !== yearB) {
|
||||
return yearA - yearB
|
||||
}
|
||||
return weekA - weekB
|
||||
}
|
||||
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
Filler
|
||||
)
|
||||
|
||||
function FullStatistics({ selectedProject, onClearSelection, data, loading, error, onRetry, currentWeekData, onNavigate }) {
|
||||
|
||||
const processData = () => {
|
||||
if (!data || data.length === 0) return null
|
||||
|
||||
// Группируем данные по проектам
|
||||
const projectsMap = {}
|
||||
|
||||
data.forEach(item => {
|
||||
const projectName = item.project_name
|
||||
const weekKey = `${item.report_year}-W${item.report_week.toString().padStart(2, '0')}`
|
||||
|
||||
if (!projectsMap[projectName]) {
|
||||
projectsMap[projectName] = {}
|
||||
}
|
||||
|
||||
projectsMap[projectName][weekKey] = parseFloat(item.total_score)
|
||||
})
|
||||
|
||||
// Собираем все уникальные недели и сортируем их по году и неделе
|
||||
const allWeeks = new Set()
|
||||
Object.values(projectsMap).forEach(weeks => {
|
||||
Object.keys(weeks).forEach(week => allWeeks.add(week))
|
||||
})
|
||||
const sortedWeeks = Array.from(allWeeks).sort(compareWeekKeys)
|
||||
|
||||
// Получаем отсортированный список всех проектов для синхронизации цветов
|
||||
const allProjectNames = getAllProjectsSorted(data)
|
||||
|
||||
// Фильтруем по выбранному проекту, если он указан
|
||||
let projectNames = allProjectNames.filter(name => projectsMap[name])
|
||||
|
||||
// Сортируем проекты так же, как на экране списка проектов (по priority и min_goal_score)
|
||||
if (currentWeekData) {
|
||||
projectNames = sortProjectsLikeCurrentWeek(projectNames, currentWeekData)
|
||||
}
|
||||
|
||||
if (selectedProject) {
|
||||
projectNames = projectNames.filter(name => name === selectedProject)
|
||||
}
|
||||
|
||||
const datasets = projectNames.map((projectName) => {
|
||||
let cumulativeSum = 0
|
||||
const values = sortedWeeks.map(week => {
|
||||
const score = projectsMap[projectName]?.[week] || 0
|
||||
cumulativeSum += score
|
||||
return cumulativeSum
|
||||
})
|
||||
|
||||
// Генерируем цвет для линии на основе индекса в полном списке проектов
|
||||
const color = getProjectColor(projectName, allProjectNames)
|
||||
|
||||
return {
|
||||
label: projectName,
|
||||
data: values,
|
||||
borderColor: color,
|
||||
backgroundColor: color.replace('50%)', '20%)'),
|
||||
fill: false,
|
||||
tension: 0.4,
|
||||
pointRadius: 4,
|
||||
pointHoverRadius: 6,
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
labels: sortedWeeks,
|
||||
datasets: datasets,
|
||||
}
|
||||
}
|
||||
|
||||
const chartData = processData()
|
||||
|
||||
if (error && !chartData && !loading) {
|
||||
if (error && (!data || data.length === 0) && !loading) {
|
||||
return <LoadingError onRetry={onRetry} />
|
||||
}
|
||||
|
||||
const chartOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
labels: {
|
||||
usePointStyle: true,
|
||||
padding: 15,
|
||||
font: {
|
||||
size: 12,
|
||||
},
|
||||
},
|
||||
padding: {
|
||||
top: 20,
|
||||
},
|
||||
},
|
||||
title: {
|
||||
display: false,
|
||||
},
|
||||
tooltip: {
|
||||
mode: 'index',
|
||||
intersect: false,
|
||||
callbacks: {
|
||||
label: function(context) {
|
||||
return `${context.dataset.label}: ${context.parsed.y.toFixed(2)}`
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
display: true,
|
||||
title: {
|
||||
display: false,
|
||||
},
|
||||
grid: {
|
||||
display: true,
|
||||
color: 'rgba(0, 0, 0, 0.05)',
|
||||
},
|
||||
},
|
||||
y: {
|
||||
display: true,
|
||||
title: {
|
||||
display: false,
|
||||
},
|
||||
beginAtZero: true,
|
||||
grid: {
|
||||
display: true,
|
||||
color: 'rgba(0, 0, 0, 0.05)',
|
||||
},
|
||||
},
|
||||
},
|
||||
interaction: {
|
||||
mode: 'nearest',
|
||||
axis: 'x',
|
||||
intersect: false,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto">
|
||||
@@ -194,24 +25,19 @@ function FullStatistics({ selectedProject, onClearSelection, data, loading, erro
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
{loading && !chartData ? (
|
||||
{loading && (!data || data.length === 0) ? (
|
||||
<div className="fixed inset-0 flex justify-center items-center">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="w-12 h-12 border-4 border-indigo-200 border-t-indigo-600 rounded-full animate-spin mb-4"></div>
|
||||
<div className="text-gray-600 font-medium">Загрузка...</div>
|
||||
</div>
|
||||
</div>
|
||||
) : !chartData ? (
|
||||
) : (!data || data.length === 0) ? (
|
||||
<div className="flex justify-center items-center py-16">
|
||||
<div className="text-gray-500 text-lg">Нет данных для отображения</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ height: '550px', paddingTop: '100px' }}>
|
||||
<Line data={chartData} options={chartOptions} />
|
||||
</div>
|
||||
<WeekProgressChart data={data} allProjectsSorted={getAllProjectsSorted(data)} currentWeekData={currentWeekData} selectedProject={selectedProject} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -18,14 +18,55 @@ const compareWeekKeys = (a, b) => {
|
||||
return weekA - weekB
|
||||
}
|
||||
|
||||
function WeekProgressChart({ data, allProjectsSorted, currentWeekData, selectedProject }) {
|
||||
if (!data || data.length === 0) {
|
||||
return null
|
||||
// Функция для определения текущей недели в ISO формате
|
||||
// ISO 8601: неделя начинается с понедельника, первая неделя года - та, в которой есть 4 января
|
||||
const getCurrentWeek = () => {
|
||||
const now = new Date()
|
||||
const date = new Date(now.getTime())
|
||||
|
||||
// ISO week calculation
|
||||
// Set to nearest Thursday: current date + 4 - current day number
|
||||
// Make Sunday's day number 7
|
||||
const day = date.getDay() || 7
|
||||
date.setDate(date.getDate() + 4 - day)
|
||||
|
||||
// Get first day of year
|
||||
const yearStart = new Date(date.getFullYear(), 0, 1)
|
||||
|
||||
// Calculate full weeks to nearest Thursday
|
||||
const week = Math.ceil((((date - yearStart) / 86400000) + 1) / 7)
|
||||
|
||||
// ISO year is the year of the Thursday of that week
|
||||
const year = date.getFullYear()
|
||||
|
||||
return { year, week }
|
||||
}
|
||||
|
||||
function WeekProgressChart({ data, allProjectsSorted, currentWeekData, selectedProject }) {
|
||||
// Определяем текущую неделю
|
||||
const currentWeek = getCurrentWeek()
|
||||
const currentWeekKey = formatWeekKey(currentWeek)
|
||||
|
||||
// Используем переданный отсортированный список проектов или получаем из данных
|
||||
const allProjects = allProjectsSorted || (() => {
|
||||
if (!data || data.length === 0) {
|
||||
// Если нет данных, используем проекты из currentWeekData
|
||||
if (currentWeekData && currentWeekData.projects) {
|
||||
return currentWeekData.projects.map(p => p.project_name || p.name).filter(Boolean)
|
||||
}
|
||||
return []
|
||||
}
|
||||
const allProjectsSet = new Set()
|
||||
data.forEach(item => {
|
||||
allProjectsSet.add(item.project_name)
|
||||
})
|
||||
return Array.from(allProjectsSet).sort()
|
||||
})()
|
||||
|
||||
// Группируем данные по неделям
|
||||
const weeksMap = {}
|
||||
|
||||
if (data && data.length > 0) {
|
||||
data.forEach(item => {
|
||||
// Фильтруем по выбранному проекту, если он указан
|
||||
if (selectedProject && item.project_name !== selectedProject) {
|
||||
@@ -43,25 +84,70 @@ function WeekProgressChart({ data, allProjectsSorted, currentWeekData, selectedP
|
||||
score: parseFloat(item.total_score) || 0
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Добавляем данные текущей недели из currentWeekData, если они отсутствуют в data
|
||||
if (currentWeekData && currentWeekData.projects && !weeksMap[currentWeekKey]) {
|
||||
const projects = Array.isArray(currentWeekData.projects)
|
||||
? currentWeekData.projects
|
||||
: (currentWeekData.projects?.projects || [])
|
||||
|
||||
weeksMap[currentWeekKey] = projects.map(project => ({
|
||||
projectName: project.project_name || project.name,
|
||||
score: parseFloat(project.total_score || project.score || 0)
|
||||
})).filter(p => {
|
||||
// Фильтруем по выбранному проекту, если он указан
|
||||
if (selectedProject && p.projectName !== selectedProject) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
// Получаем все уникальные недели и сортируем их (новые сверху)
|
||||
const allWeeks = Object.keys(weeksMap).sort((a, b) => -compareWeekKeys(a, b))
|
||||
|
||||
// Берем первые 4 недели (самые актуальные)
|
||||
const last4Weeks = allWeeks.slice(0, 4)
|
||||
// Находим индекс текущей недели
|
||||
const currentWeekIndex = allWeeks.findIndex(w => w === currentWeekKey)
|
||||
|
||||
// Используем переданный отсортированный список проектов или получаем из данных
|
||||
const allProjects = allProjectsSorted || (() => {
|
||||
const allProjectsSet = new Set()
|
||||
data.forEach(item => {
|
||||
allProjectsSet.add(item.project_name)
|
||||
})
|
||||
return Array.from(allProjectsSet).sort()
|
||||
})()
|
||||
// Разделяем недели на группы
|
||||
// allWeeks отсортированы от новых к старым (индекс 0 - самая новая)
|
||||
// Если currentWeekIndex = 2:
|
||||
// - allWeeks[0, 1] - более новые недели (будущие, если есть)
|
||||
// - allWeeks[2] - текущая неделя
|
||||
// - allWeeks[3, 4, ...] - более старые недели (прошлые)
|
||||
let last4Weeks = [] // Последние 4 недели ДО текущей (более старые)
|
||||
let next4Weeks = [] // Следующие 4 недели ПОСЛЕ текущей (более новые, если есть)
|
||||
let currentWeekInData = null
|
||||
|
||||
// Обрабатываем данные для каждой недели
|
||||
const weeksData = last4Weeks.map(weekKey => {
|
||||
const weekProjects = weeksMap[weekKey]
|
||||
if (currentWeekIndex >= 0) {
|
||||
// Текущая неделя найдена в данных
|
||||
// Берем 4 недели ДО текущей (более старые) - это индексы после currentWeekIndex
|
||||
last4Weeks = allWeeks.slice(currentWeekIndex + 1, currentWeekIndex + 5)
|
||||
// Берем 4 недели ПОСЛЕ текущей (более новые) - это индексы до currentWeekIndex
|
||||
next4Weeks = allWeeks.slice(Math.max(0, currentWeekIndex - 4), currentWeekIndex)
|
||||
// Текущая неделя
|
||||
currentWeekInData = currentWeekKey
|
||||
} else {
|
||||
// Текущая неделя не найдена в данных, но мы её добавили из currentWeekData
|
||||
if (weeksMap[currentWeekKey]) {
|
||||
// Добавляем текущую неделю в начало списка (она самая новая)
|
||||
const weeksWithCurrent = [currentWeekKey, ...allWeeks]
|
||||
// Последние 4 недели - это первые 4 из старых данных (более старые)
|
||||
last4Weeks = allWeeks.slice(0, 4)
|
||||
// Следующие недели после текущей - их нет, так как текущая самая новая
|
||||
next4Weeks = []
|
||||
currentWeekInData = currentWeekKey
|
||||
} else {
|
||||
// Если текущей недели нет вообще, просто берем последние 4 (самые новые из доступных)
|
||||
last4Weeks = allWeeks.slice(0, 4)
|
||||
next4Weeks = []
|
||||
}
|
||||
}
|
||||
|
||||
// Функция для обработки данных недели
|
||||
const processWeekData = (weekKey) => {
|
||||
const weekProjects = weeksMap[weekKey] || []
|
||||
const totalScore = weekProjects.reduce((sum, p) => sum + p.score, 0)
|
||||
|
||||
// Используем абсолютные значения (баллы)
|
||||
@@ -93,23 +179,36 @@ function WeekProgressChart({ data, allProjectsSorted, currentWeekData, selectedP
|
||||
year,
|
||||
week,
|
||||
projects: sortedProjectsWithData,
|
||||
totalScore
|
||||
totalScore,
|
||||
isCurrent: weekKey === currentWeekKey
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Обрабатываем данные для всех недель
|
||||
const currentWeekDataProcessed = currentWeekInData ? processWeekData(currentWeekInData) : null
|
||||
|
||||
// Объединяем все недели кроме текущей
|
||||
// next4Weeks уже отсортированы от новых к старым (индексы 0..currentWeekIndex-1)
|
||||
// last4Weeks уже отсортированы от новых к старым (индексы currentWeekIndex+1..)
|
||||
// Для отображения: старые сверху, новые снизу - нужно перевернуть порядок
|
||||
// Объединяем: сначала более старые (last4Weeks), потом более новые (next4Weeks)
|
||||
const allOtherWeeks = [...last4Weeks, ...next4Weeks]
|
||||
// Переворачиваем, чтобы старые были сверху, новые снизу
|
||||
const allOtherWeeksData = allOtherWeeks.map(processWeekData).reverse()
|
||||
|
||||
// Объединяем все недели для расчета максимального значения
|
||||
const allWeeksData = [...(currentWeekDataProcessed ? [currentWeekDataProcessed] : []), ...allOtherWeeksData]
|
||||
|
||||
// Находим максимальное значение среди всех недель для единой шкалы сравнения
|
||||
const maxTotalScore = Math.max(...weeksData.map(w => w.totalScore), 1)
|
||||
const maxTotalScore = Math.max(...allWeeksData.map(w => w.totalScore), 1)
|
||||
|
||||
if (weeksData.length === 0) {
|
||||
if (allWeeksData.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-8">
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-4">Последние 4 недели</h2>
|
||||
<div className="space-y-3">
|
||||
{weeksData.map((weekData) => (
|
||||
<div key={weekData.weekKey} className="flex items-center gap-3">
|
||||
// Компонент для отображения прогрессбара недели
|
||||
const WeekProgressBar = ({ weekData }) => (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="min-w-[100px] text-sm font-medium text-gray-700">
|
||||
Неделя {weekData.week}
|
||||
</div>
|
||||
@@ -150,7 +249,29 @@ function WeekProgressChart({ data, allProjectsSorted, currentWeekData, selectedP
|
||||
{weekData.totalScore > 0 ? `${weekData.totalScore.toFixed(1)}` : '-'}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="mt-8">
|
||||
<h2 className="text-2xl font-semibold text-gray-800 mb-6">Прогресс недель</h2>
|
||||
<div className="space-y-3">
|
||||
{/* Остальные недели (старые сверху, новые снизу) */}
|
||||
{allOtherWeeksData.map((weekData) => (
|
||||
<WeekProgressBar key={weekData.weekKey} weekData={weekData} />
|
||||
))}
|
||||
|
||||
{/* Разделитель перед текущей неделей */}
|
||||
{currentWeekDataProcessed && (
|
||||
<div className="border-t border-gray-300 my-3"></div>
|
||||
)}
|
||||
|
||||
{/* Текущая неделя (последняя) */}
|
||||
{currentWeekDataProcessed && (
|
||||
<>
|
||||
<h3 className="text-lg font-semibold text-gray-800 mb-2">Текущая неделя</h3>
|
||||
<WeekProgressBar weekData={currentWeekDataProcessed} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user