Files
play-life/play-life-web/src/components/TaskList.jsx
poignatov 647c549ec9
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 45s
feat: добавлен раздел 'Бесконечные' для задач с периодичностью 0
2026-01-06 14:31:00 +03:00

503 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (
<div
key={task.id}
className="task-item"
onClick={() => handleTaskClick(task)}
>
<div className="task-item-content">
<div
className={`task-checkmark ${showDetailOnCheckmark ? 'task-checkmark-detail' : ''}`}
onClick={(e) => handleCheckmarkClick(task, e)}
title={showDetailOnCheckmark ? 'Открыть детали' : 'Выполнить задачу'}
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="10" cy="10" r="9" stroke="currentColor" strokeWidth="2" fill="none" className="checkmark-circle" />
<path d="M6 10 L9 13 L14 7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="checkmark-check" />
</svg>
</div>
<div className="task-name">{task.name}</div>
<div className="task-actions">
<span className="task-completed-count">{task.completed}</span>
</div>
</div>
</div>
)
}
// Показываем загрузку только если данных нет и это не фоновая загрузка
// Проверяем наличие данных более надежно: либо в 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 (
<div className="task-list">
<div className="loading">Загрузка...</div>
</div>
)
}
const projectNames = Object.keys(groupedTasks).sort()
return (
<div className="task-list">
{toast && (
<Toast
message={toast.message}
onClose={() => setToast(null)}
/>
)}
<button onClick={handleAddClick} className="add-task-button">
Добавить
</button>
{loadingDetails && tasks.length > 0 && (
<div className="loading-details">Загрузка деталей задач...</div>
)}
{projectNames.length === 0 && !loading && tasks.length === 0 && (
<div className="empty-state">
<p>Задач пока нет. Добавьте задачу через кнопку "Добавить".</p>
</div>
)}
{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 (
<div key={projectName} className="project-group">
<div className="project-group-header">
<h3 className="project-group-title">{projectName}</h3>
</div>
{group.notCompleted.length > 0 && (
<div className="task-group">
{group.notCompleted.map(renderTaskItem)}
</div>
)}
{hasInfinite && (
<div className="completed-section">
<button
className="completed-toggle"
onClick={() => toggleInfiniteExpanded(projectName)}
>
<span className="completed-toggle-icon">
{isInfiniteExpanded ? '▼' : '▶'}
</span>
<span>Бесконечные ({group.infinite.length})</span>
</button>
{isInfiniteExpanded && (
<div className="task-group completed-tasks">
{group.infinite.map(renderTaskItem)}
</div>
)}
</div>
)}
{hasCompleted && (
<div className="completed-section">
<button
className="completed-toggle"
onClick={() => toggleCompletedExpanded(projectName)}
>
<span className="completed-toggle-icon">
{isCompletedExpanded ? '▼' : '▶'}
</span>
<span>Выполненные ({group.completed.length})</span>
</button>
{isCompletedExpanded && (
<div className="task-group completed-tasks">
{group.completed.map(renderTaskItem)}
</div>
)}
</div>
)}
{group.notCompleted.length === 0 && !hasCompleted && !hasInfinite && (
<div className="empty-group">Нет задач в этой группе</div>
)}
</div>
)
})}
{/* Модальное окно для деталей задачи */}
{selectedTaskForDetail && (
<TaskDetail
taskId={selectedTaskForDetail}
onClose={handleCloseDetail}
onRefresh={onRefresh}
onTaskCompleted={() => setToast({ message: 'Задача выполнена' })}
/>
)}
</div>
)
}
export default TaskList