import React, { useState, useEffect, useMemo, useRef } from 'react' import { useAuth } from './auth/AuthContext' import TaskDetail from './TaskDetail' import Toast from './Toast' import './TaskList.css' const API_URL = '/api/tasks' function TaskList({ onNavigate, data, loading, backgroundLoading, onRefresh }) { const { authFetch } = useAuth() // Инициализируем tasks из data, если data есть, иначе пустой массив const [tasks, setTasks] = useState(() => data && Array.isArray(data) ? data : []) const [taskDetails, setTaskDetails] = useState({}) const [loadingDetails, setLoadingDetails] = useState(false) const [selectedTaskForDetail, setSelectedTaskForDetail] = useState(null) const [isCompleting, setIsCompleting] = useState(false) const [expandedCompleted, setExpandedCompleted] = useState({}) // Загружаем состояние раскрытия "Бесконечные" из localStorage (по умолчанию true) const [expandedInfinite, setExpandedInfinite] = useState(() => { try { const saved = localStorage.getItem('taskList_expandedInfinite') return saved ? JSON.parse(saved) : {} } catch { return {} } }) const [toast, setToast] = useState(null) // Для отслеживания изменений в списке задач (чтобы не перезагружать детали без необходимости) const lastTaskIdsRef = useRef('') useEffect(() => { if (data) { setTasks(data) } }, [data]) // Загрузка данных управляется из App.jsx через loadTabData // TaskList не инициирует загрузку самостоятельно // Загружаем детали для всех задач // Оптимизация: загружаем только если список задач изменился (по id и last_completed_at) useEffect(() => { if (!tasks || tasks.length === 0) { setTaskDetails({}) lastTaskIdsRef.current = '' return } // Создаем ключ из id и last_completed_at всех задач const taskKey = tasks.map(t => `${t.id}:${t.last_completed_at || ''}`).sort().join(',') // Если ключ не изменился, не перезагружаем детали if (taskKey === lastTaskIdsRef.current) { return } lastTaskIdsRef.current = taskKey const loadTaskDetails = async () => { // Не показываем индикатор загрузки если детали уже есть (фоновое обновление) const hasExistingDetails = Object.keys(taskDetails).length > 0 if (!hasExistingDetails) { setLoadingDetails(true) } try { const detailPromises = tasks.map(async (task) => { try { const response = await authFetch(`${API_URL}/${task.id}`) if (response.ok) { const detail = await response.json() return { taskId: task.id, detail } } } catch (err) { console.error(`Error loading task detail for ${task.id}:`, err) } return null }) const details = await Promise.all(detailPromises) const detailsMap = {} details.forEach(item => { if (item) { detailsMap[item.taskId] = item.detail } }) setTaskDetails(detailsMap) } catch (err) { console.error('Error loading task details:', err) } finally { setLoadingDetails(false) } } loadTaskDetails() }, [tasks, authFetch]) const handleTaskClick = (task) => { onNavigate?.('task-form', { taskId: task.id }) } const handleCheckmarkClick = async (task, e) => { e.stopPropagation() const detail = taskDetails[task.id] const hasProgression = detail?.task?.progression_base != null const hasSubtasks = detail?.subtasks && detail.subtasks.length > 0 if (hasProgression || hasSubtasks) { // Открываем экран details setSelectedTaskForDetail(task.id) } else { // Отправляем задачу setIsCompleting(true) try { const response = await authFetch(`${API_URL}/${task.id}/complete`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({}), }) if (!response.ok) { const errorData = await response.json().catch(() => ({})) throw new Error(errorData.message || 'Ошибка при выполнении задачи') } // Показываем toast о выполнении задачи setToast({ message: 'Задача выполнена' }) // Обновляем список if (onRefresh) { onRefresh() } } catch (err) { console.error('Error completing task:', err) alert(err.message || 'Ошибка при выполнении задачи') } finally { setIsCompleting(false) } } } const handleCloseDetail = () => { setSelectedTaskForDetail(null) } const handleAddClick = () => { onNavigate?.('task-form', { taskId: undefined }) } const toggleCompletedExpanded = (projectName) => { setExpandedCompleted(prev => ({ ...prev, [projectName]: !prev[projectName] })) } const toggleInfiniteExpanded = (projectName) => { setExpandedInfinite(prev => { const newState = { ...prev, [projectName]: !prev[projectName] } // Сохраняем в localStorage try { localStorage.setItem('taskList_expandedInfinite', JSON.stringify(newState)) } catch (err) { console.error('Error saving expandedInfinite to localStorage:', err) } return newState }) } // Получаем все проекты из наград задачи и подзадач const getTaskProjects = (task) => { const projects = new Set() const detail = taskDetails[task.id] if (detail) { // Проекты из основной задачи if (detail.rewards) { detail.rewards.forEach(reward => { if (reward.project_name) { projects.add(reward.project_name) } }) } // Проекты из подзадач if (detail.subtasks) { detail.subtasks.forEach(subtask => { if (subtask.rewards) { subtask.rewards.forEach(reward => { if (reward.project_name) { projects.add(reward.project_name) } }) } }) } } return Array.from(projects) } // Функция для проверки, является ли период нулевым const isZeroPeriod = (intervalStr) => { if (!intervalStr) return false const parts = intervalStr.trim().split(/\s+/) if (parts.length < 1) return false const value = parseInt(parts[0], 10) return !isNaN(value) && value === 0 } // Функция для парсинга PostgreSQL INTERVAL и добавления к дате const addIntervalToDate = (date, intervalStr) => { if (!intervalStr) return null const result = new Date(date) // Парсим строку интервала (формат: "1 day", "2 hours", "3 months", etc.) const parts = intervalStr.trim().split(/\s+/) if (parts.length < 2) return null const value = parseInt(parts[0], 10) if (isNaN(value)) return null const unit = parts[1].toLowerCase() switch (unit) { case 'minute': case 'minutes': result.setMinutes(result.getMinutes() + value) break case 'hour': case 'hours': result.setHours(result.getHours() + value) break case 'day': case 'days': result.setDate(result.getDate() + value) break case 'week': case 'weeks': result.setDate(result.getDate() + value * 7) break case 'month': case 'months': result.setMonth(result.getMonth() + value) break case 'year': case 'years': result.setFullYear(result.getFullYear() + value) break default: return null } return result } // Группируем задачи по проектам const groupedTasks = useMemo(() => { const today = new Date() today.setHours(0, 0, 0, 0) const groups = {} tasks.forEach(task => { const projects = getTaskProjects(task) // Если у задачи нет проектов, добавляем в группу "Без проекта" if (projects.length === 0) { projects.push('Без проекта') } // Определяем, в какую группу попадает задача let isCompleted = false let isInfinite = false // Если у задачи период повторения = 0, она в бесконечных if (task.repetition_period && isZeroPeriod(task.repetition_period)) { isInfinite = true isCompleted = false } else if (task.repetition_period) { // Если есть repetition_period (и он не 0), проверяем логику повторения if (task.last_completed_at) { const lastCompleted = new Date(task.last_completed_at) const nextDueDate = addIntervalToDate(lastCompleted, task.repetition_period) if (nextDueDate) { // Округляем до начала дня nextDueDate.setHours(0, 0, 0, 0) // Если nextDueDate > today, то задача в выполненных isCompleted = nextDueDate.getTime() > today.getTime() } else { // Если не удалось распарсить интервал, используем старую логику const completedDate = new Date(task.last_completed_at) completedDate.setHours(0, 0, 0, 0) isCompleted = completedDate.getTime() === today.getTime() } } else { // Если нет last_completed_at, то в обычной группе isCompleted = false } } else { // Если repetition_period == null, используем старую логику if (task.last_completed_at) { const completedDate = new Date(task.last_completed_at) completedDate.setHours(0, 0, 0, 0) isCompleted = completedDate.getTime() === today.getTime() } else { isCompleted = false } } projects.forEach(projectName => { if (!groups[projectName]) { groups[projectName] = { notCompleted: [], completed: [], infinite: [] } } if (isInfinite) { groups[projectName].infinite.push(task) } else if (isCompleted) { groups[projectName].completed.push(task) } else { groups[projectName].notCompleted.push(task) } }) }) return groups }, [tasks, taskDetails]) const renderTaskItem = (task) => { const detail = taskDetails[task.id] const hasProgression = detail?.task?.progression_base != null const hasSubtasks = detail?.subtasks && detail.subtasks.length > 0 const showDetailOnCheckmark = hasProgression || hasSubtasks return (
handleTaskClick(task)} >
handleCheckmarkClick(task, e)} title={showDetailOnCheckmark ? 'Открыть детали' : 'Выполнить задачу'} >
{task.name}
{task.completed}
) } // Показываем загрузку только если данных нет и это не фоновая загрузка // Проверяем наличие данных более надежно: либо в data, либо в tasks // Важно: проверяем оба источника данных, так как они могут обновляться асинхронно const hasDataInProps = data && Array.isArray(data) && data.length > 0 const hasDataInState = tasks && Array.isArray(tasks) && tasks.length > 0 const hasData = hasDataInProps || hasDataInState // Показываем загрузку только если: // 1. Идет загрузка (loading = true) // 2. Это не фоновая загрузка (backgroundLoading = false) // 3. Данных нет (hasData = false) // Это предотвращает показ загрузки при переключении табов, когда данные уже есть if (loading && !backgroundLoading && !hasData) { return (
Загрузка...
) } const projectNames = Object.keys(groupedTasks).sort() return (
{toast && ( setToast(null)} /> )} {loadingDetails && tasks.length > 0 && (
Загрузка деталей задач...
)} {projectNames.length === 0 && !loading && tasks.length === 0 && (

Задач пока нет. Добавьте задачу через кнопку "Добавить".

)} {projectNames.map(projectName => { const group = groupedTasks[projectName] const hasCompleted = group.completed.length > 0 const hasInfinite = group.infinite.length > 0 const isCompletedExpanded = expandedCompleted[projectName] // По умолчанию бесконечные раскрыты (true), если не сохранено иное const isInfiniteExpanded = expandedInfinite[projectName] !== undefined ? expandedInfinite[projectName] : true return (

{projectName}

{group.notCompleted.length > 0 && (
{group.notCompleted.map(renderTaskItem)}
)} {hasInfinite && (
{isInfiniteExpanded && (
{group.infinite.map(renderTaskItem)}
)}
)} {hasCompleted && (
{isCompletedExpanded && (
{group.completed.map(renderTaskItem)}
)}
)} {group.notCompleted.length === 0 && !hasCompleted && !hasInfinite && (
Нет задач в этой группе
)}
) })} {/* Модальное окно для деталей задачи */} {selectedTaskForDetail && ( setToast({ message: 'Задача выполнена' })} /> )}
) } export default TaskList