4.12.0: Добавлены записи за день на экране статистики
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m28s
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m28s
This commit is contained in:
@@ -1,13 +1,107 @@
|
||||
import React from 'react'
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import WeekProgressChart from './WeekProgressChart'
|
||||
import LoadingError from './LoadingError'
|
||||
import TodayEntriesList from './TodayEntriesList'
|
||||
import { getAllProjectsSorted } from '../utils/projectUtils'
|
||||
import './Integrations.css'
|
||||
|
||||
// Экспортируем для обратной совместимости (если используется в других местах)
|
||||
export { getProjectColorByIndex } from '../utils/projectUtils'
|
||||
|
||||
function FullStatistics({ selectedProject, onClearSelection, data, loading, error, onRetry, currentWeekData, onNavigate }) {
|
||||
// Функция для получения дат текущей недели (понедельник - воскресенье)
|
||||
const getCurrentWeekDates = () => {
|
||||
const now = new Date()
|
||||
const day = now.getDay()
|
||||
// Вычисляем разницу до понедельника (1 = понедельник, 0 = воскресенье)
|
||||
const diff = day === 0 ? -6 : 1 - day
|
||||
const monday = new Date(now)
|
||||
monday.setDate(now.getDate() + diff)
|
||||
|
||||
const dates = []
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const date = new Date(monday)
|
||||
date.setDate(monday.getDate() + i)
|
||||
dates.push(date)
|
||||
}
|
||||
return dates
|
||||
}
|
||||
|
||||
// Функция для форматирования даты в YYYY-MM-DD
|
||||
const formatDate = (date) => {
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
// Названия дней недели
|
||||
const dayNames = ['пн', 'вт', 'ср', 'чт', 'пт', 'сб', 'вс']
|
||||
|
||||
function FullStatistics({ selectedProject, onClearSelection, data, loading, error, onRetry, currentWeekData, onNavigate, todayEntries, todayEntriesLoading, todayEntriesError, onRetryTodayEntries, fetchTodayEntries }) {
|
||||
const [selectedDate, setSelectedDate] = useState(null)
|
||||
const prevVisibleRef = React.useRef(false)
|
||||
|
||||
// Получаем даты текущей недели
|
||||
const weekDates = getCurrentWeekDates()
|
||||
|
||||
// Определяем текущий день (используем useMemo для стабильности)
|
||||
const today = React.useMemo(() => {
|
||||
const date = new Date()
|
||||
date.setHours(0, 0, 0, 0)
|
||||
return date
|
||||
}, [])
|
||||
|
||||
// Получаем строковое представление сегодняшней даты
|
||||
const todayDateStr = React.useMemo(() => formatDate(today), [today])
|
||||
|
||||
// Фильтруем только прошедшие дни (включая сегодня)
|
||||
const pastDays = weekDates.filter((date) => {
|
||||
const dateOnly = new Date(date)
|
||||
dateOnly.setHours(0, 0, 0, 0)
|
||||
return dateOnly <= today
|
||||
})
|
||||
|
||||
// Отслеживаем, когда компонент становится видимым
|
||||
const prevActiveTabRef = React.useRef(null)
|
||||
|
||||
// Инициализируем выбранную дату текущим днем при первом рендере
|
||||
// Также проверяем, что выбранная дата все еще в списке доступных дней
|
||||
useEffect(() => {
|
||||
const pastDaysDateStrs = pastDays.map(date => formatDate(date))
|
||||
|
||||
if (selectedDate === null) {
|
||||
// Первая инициализация - устанавливаем текущий день
|
||||
setSelectedDate(todayDateStr)
|
||||
} else if (!pastDaysDateStrs.includes(selectedDate)) {
|
||||
// Если выбранная дата больше не в списке доступных (например, прошла неделя)
|
||||
// Сбрасываем на текущий день
|
||||
setSelectedDate(todayDateStr)
|
||||
}
|
||||
}, [selectedDate, todayDateStr, pastDays])
|
||||
|
||||
// Отслеживаем открытие компонента и загружаем данные для selectedDate
|
||||
useEffect(() => {
|
||||
// Этот эффект срабатывает при каждом рендере, но мы проверяем, нужно ли загружать данные
|
||||
if (selectedDate && fetchTodayEntries) {
|
||||
// Всегда загружаем данные для selectedDate при его изменении
|
||||
// Это гарантирует, что данные соответствуют выбранному чипсу
|
||||
fetchTodayEntries(false, selectedProject, selectedDate)
|
||||
}
|
||||
}, [selectedDate, selectedProject, fetchTodayEntries])
|
||||
|
||||
// Загружаем данные при изменении selectedDate или selectedProject
|
||||
useEffect(() => {
|
||||
if (selectedDate && fetchTodayEntries) {
|
||||
fetchTodayEntries(false, selectedProject, selectedDate)
|
||||
}
|
||||
}, [selectedDate, selectedProject, fetchTodayEntries])
|
||||
|
||||
// Обработчик выбора дня
|
||||
const handleDaySelect = useCallback((date) => {
|
||||
const dateStr = formatDate(date)
|
||||
setSelectedDate(dateStr)
|
||||
// Загрузка данных произойдет автоматически через useEffect выше
|
||||
}, [])
|
||||
|
||||
if (error && (!data || data.length === 0) && !loading) {
|
||||
return <LoadingError onRetry={onRetry} />
|
||||
@@ -37,7 +131,51 @@ function FullStatistics({ selectedProject, onClearSelection, data, loading, erro
|
||||
<div className="text-gray-500 text-lg">Нет данных для отображения</div>
|
||||
</div>
|
||||
) : (
|
||||
<WeekProgressChart data={data} allProjectsSorted={getAllProjectsSorted(data)} currentWeekData={currentWeekData} selectedProject={selectedProject} />
|
||||
<>
|
||||
<WeekProgressChart data={data} allProjectsSorted={getAllProjectsSorted(data)} currentWeekData={currentWeekData} selectedProject={selectedProject} />
|
||||
|
||||
{/* Чипсы дней недели */}
|
||||
{pastDays.length > 0 && (
|
||||
<div className="mt-3 mb-2">
|
||||
<div className="flex flex-wrap gap-2.5">
|
||||
{pastDays.map((date, index) => {
|
||||
const dateStr = formatDate(date)
|
||||
const dayOfWeek = index + 1 // 1 = понедельник
|
||||
const isSelected = selectedDate === dateStr
|
||||
const isToday = dateStr === todayDateStr
|
||||
|
||||
return (
|
||||
<button
|
||||
key={dateStr}
|
||||
onClick={() => handleDaySelect(date)}
|
||||
className={`
|
||||
h-9 px-4 rounded-lg text-sm font-semibold
|
||||
transition-all duration-200 ease-in-out
|
||||
flex items-center justify-center
|
||||
${
|
||||
isSelected
|
||||
? 'bg-white text-gray-900 shadow-sm border border-gray-200'
|
||||
: 'bg-transparent text-gray-700 border border-gray-300 hover:border-gray-400'
|
||||
}
|
||||
${isToday && !isSelected ? 'ring-2 ring-indigo-200 border-indigo-300' : ''}
|
||||
active:scale-95
|
||||
`}
|
||||
>
|
||||
{dayNames[dayOfWeek - 1]}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<TodayEntriesList
|
||||
data={todayEntries}
|
||||
loading={todayEntriesLoading}
|
||||
error={todayEntriesError}
|
||||
onRetry={() => fetchTodayEntries && fetchTodayEntries(false, selectedProject, selectedDate)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
180
play-life-web/src/components/TodayEntriesList.jsx
Normal file
180
play-life-web/src/components/TodayEntriesList.jsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import React from 'react'
|
||||
import LoadingError from './LoadingError'
|
||||
|
||||
// Функция для форматирования скорa (аналогично formatScore из TaskDetail)
|
||||
const formatScore = (num) => {
|
||||
if (num === 0) return '0'
|
||||
|
||||
let str = num.toPrecision(4)
|
||||
str = str.replace(/\.?0+$/, '')
|
||||
|
||||
if (str.includes('e+') || str.includes('e-')) {
|
||||
const numValue = parseFloat(str)
|
||||
if (Math.abs(numValue) >= 10000) {
|
||||
return str
|
||||
}
|
||||
return numValue.toString().replace(/\.?0+$/, '')
|
||||
}
|
||||
|
||||
return str
|
||||
}
|
||||
|
||||
// Функция для форматирования текста с заменой плейсхолдеров на nodes
|
||||
const formatEntryText = (text, nodes) => {
|
||||
if (!text || !nodes || nodes.length === 0) {
|
||||
return text
|
||||
}
|
||||
|
||||
// Создаем map для быстрого доступа к nodes по индексу
|
||||
const nodesMap = {}
|
||||
nodes.forEach(node => {
|
||||
nodesMap[node.index] = node
|
||||
})
|
||||
|
||||
// Создаем массив для хранения частей текста и React элементов
|
||||
const parts = []
|
||||
let lastIndex = 0
|
||||
let currentText = text
|
||||
|
||||
// Сначала защищаем экранированные плейсхолдеры
|
||||
const escapedMarkers = {}
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const escaped = `\\$${i}`
|
||||
const marker = `__ESCAPED_DOLLAR_${i}__`
|
||||
if (currentText.includes(escaped)) {
|
||||
escapedMarkers[marker] = escaped
|
||||
currentText = currentText.replace(new RegExp(escaped.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), marker)
|
||||
}
|
||||
}
|
||||
|
||||
// Заменяем ${0}, ${1}, и т.д.
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const placeholder = `\${${i}}`
|
||||
const regex = new RegExp(placeholder.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g')
|
||||
if (nodesMap[i] && currentText.includes(placeholder)) {
|
||||
const node = nodesMap[i]
|
||||
const scoreStr = node.score >= 0
|
||||
? `${node.project_name}+${formatScore(node.score)}`
|
||||
: `${node.project_name}-${formatScore(Math.abs(node.score))}`
|
||||
currentText = currentText.replace(regex, `__NODE_${i}__`)
|
||||
// Сохраняем информацию о замене
|
||||
if (!escapedMarkers[`__NODE_${i}__`]) {
|
||||
escapedMarkers[`__NODE_${i}__`] = { type: 'node', text: scoreStr }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Заменяем $0, $1, и т.д. (с конца, чтобы не заменить $1 в $10)
|
||||
for (let i = 99; i >= 0; i--) {
|
||||
if (nodesMap[i]) {
|
||||
const node = nodesMap[i]
|
||||
const scoreStr = node.score >= 0
|
||||
? `${node.project_name}+${formatScore(node.score)}`
|
||||
: `${node.project_name}-${formatScore(Math.abs(node.score))}`
|
||||
const regex = new RegExp(`\\$${i}(?!\\d)`, 'g')
|
||||
if (currentText.match(regex)) {
|
||||
currentText = currentText.replace(regex, `__NODE_${i}__`)
|
||||
if (!escapedMarkers[`__NODE_${i}__`]) {
|
||||
escapedMarkers[`__NODE_${i}__`] = { type: 'node', text: scoreStr }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Разбиваем текст на части и создаем React элементы
|
||||
const result = []
|
||||
let searchIndex = 0
|
||||
|
||||
while (searchIndex < currentText.length) {
|
||||
// Ищем следующий маркер
|
||||
let foundMarker = null
|
||||
let markerIndex = currentText.length
|
||||
|
||||
// Ищем все маркеры
|
||||
for (const marker in escapedMarkers) {
|
||||
const index = currentText.indexOf(marker, searchIndex)
|
||||
if (index !== -1 && index < markerIndex) {
|
||||
markerIndex = index
|
||||
foundMarker = marker
|
||||
}
|
||||
}
|
||||
|
||||
// Если нашли маркер
|
||||
if (foundMarker) {
|
||||
// Добавляем текст до маркера
|
||||
if (markerIndex > searchIndex) {
|
||||
result.push(currentText.substring(searchIndex, markerIndex))
|
||||
}
|
||||
|
||||
// Добавляем элемент для маркера
|
||||
const markerData = escapedMarkers[foundMarker]
|
||||
if (markerData && markerData.type === 'node') {
|
||||
result.push(
|
||||
<strong key={`node-${searchIndex}`}>{markerData.text}</strong>
|
||||
)
|
||||
} else if (typeof markerData === 'string') {
|
||||
// Это экранированный плейсхолдер
|
||||
result.push(markerData)
|
||||
}
|
||||
|
||||
searchIndex = markerIndex + foundMarker.length
|
||||
} else {
|
||||
// Больше маркеров нет, добавляем оставшийся текст
|
||||
if (searchIndex < currentText.length) {
|
||||
result.push(currentText.substring(searchIndex))
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return result.length > 0 ? result : currentText
|
||||
}
|
||||
|
||||
function TodayEntriesList({ data, loading, error, onRetry }) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center py-8">
|
||||
<div className="w-8 h-8 border-4 border-indigo-200 border-t-indigo-600 rounded-full animate-spin"></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <LoadingError onRetry={onRetry} />
|
||||
}
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
Нет записей за выбранный день
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-2 mb-6">
|
||||
<div className="space-y-3">
|
||||
{data.map((entry) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="bg-white rounded-lg p-4 shadow-sm border border-gray-200"
|
||||
>
|
||||
<div className="text-gray-800 whitespace-pre-wrap">
|
||||
{formatEntryText(entry.text, entry.nodes)}
|
||||
</div>
|
||||
{entry.created_date && (
|
||||
<div className="text-xs text-gray-500 mt-2">
|
||||
{new Date(entry.created_date).toLocaleTimeString('ru-RU', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TodayEntriesList
|
||||
Reference in New Issue
Block a user