Files
play-life/play-life-web/src/components/TaskList.jsx
poignatov 98427f5d0e
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m22s
5.10.1: Не сбрасывать подзадачи при быстрой прогрессии
2026-03-04 15:11:11 +03:00

1298 lines
53 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 { createPortal } from 'react-dom'
import { useAuth } from './auth/AuthContext'
import TaskDetail from './TaskDetail'
import LoadingError from './LoadingError'
import Toast from './Toast'
import { DayPicker } from 'react-day-picker'
import { ru } from 'react-day-picker/locale'
import 'react-day-picker/style.css'
import './TaskList.css'
const API_URL = '/api/tasks'
function TaskList({ onNavigate, data, loading, backgroundLoading, error, onRetry, onRefresh }) {
const { authFetch } = useAuth()
// Инициализируем tasks из data, если data есть, иначе пустой массив
const [tasks, setTasks] = useState(() => data && Array.isArray(data) ? data : [])
const [selectedTaskForDetail, setSelectedTaskForDetail] = useState(null)
const [isCompleting, setIsCompleting] = useState(false)
const [expandedCompleted, setExpandedCompleted] = useState({})
const [selectedTaskForPostpone, setSelectedTaskForPostpone] = useState(null)
const [postponeDate, setPostponeDate] = useState('')
const [isPostponing, setIsPostponing] = useState(false)
const [toast, setToast] = useState(null)
const [searchQuery, setSearchQuery] = useState('')
const [savingProgressionTaskId, setSavingProgressionTaskId] = useState(null)
// Режим группировки: 'project' (по проекту - по умолчанию) или 'group' (по группе)
const [groupingMode, setGroupingMode] = useState(() => {
// Восстанавливаем из localStorage, по умолчанию 'project'
try {
const saved = localStorage.getItem('taskListGroupingMode')
return saved === 'group' ? 'group' : 'project'
} catch {
return 'project'
}
})
// Сохраняем режим группировки в localStorage при изменении
useEffect(() => {
try {
localStorage.setItem('taskListGroupingMode', groupingMode)
} catch {
// Игнорируем ошибки localStorage
}
}, [groupingMode])
useEffect(() => {
if (data) {
setTasks(data)
}
}, [data])
// Загрузка данных управляется из App.jsx через loadTabData
// TaskList не инициирует загрузку самостоятельно
const handleTaskClick = (task) => {
setSelectedTaskForDetail(task.id)
}
const handleCheckmarkClick = async (task, e) => {
e.stopPropagation()
// Для задач-тестов запускаем тест вместо открытия модального окна
const isTest = task.config_id != null
if (isTest) {
if (task.config_id) {
try {
// Загружаем детальную информацию о задаче, чтобы получить maxCards
const response = await authFetch(`${API_URL}/${task.id}`)
if (!response.ok) {
throw new Error('Ошибка при загрузке деталей задачи')
}
const taskDetail = await response.json()
// Переходим к тесту с maxCards
onNavigate?.('test', {
configId: task.config_id,
taskId: task.id,
maxCards: taskDetail.max_cards
})
} catch (err) {
console.error('Failed to load task details:', err)
// В случае ошибки всё равно переходим к тесту, но без maxCards
onNavigate?.('test', { configId: task.config_id, taskId: task.id })
}
}
return
}
// Для обычных задач открываем диалог подтверждения
setSelectedTaskForDetail(task.id)
}
const handleCloseDetail = (skipHistoryBack = false) => {
// Если skipHistoryBack = true (например, при навигации на форму редактирования),
// просто закрываем модальное окно без history.back()
if (!skipHistoryBack && historyPushedForDetailRef.current) {
window.history.back()
} else {
historyPushedForDetailRef.current = false
setSelectedTaskForDetail(null)
}
}
// Добавляем запись в историю при открытии модальных окон и обрабатываем "назад"
const historyPushedForDetailRef = useRef(false)
const historyPushedForPostponeRef = useRef(false)
const selectedTaskForDetailRef = useRef(selectedTaskForDetail)
const selectedTaskForPostponeRef = useRef(selectedTaskForPostpone)
// Обновляем refs при изменении значений
useEffect(() => {
selectedTaskForDetailRef.current = selectedTaskForDetail
selectedTaskForPostponeRef.current = selectedTaskForPostpone
}, [selectedTaskForDetail, selectedTaskForPostpone])
useEffect(() => {
if (selectedTaskForPostpone && !historyPushedForPostponeRef.current) {
// Добавляем запись в историю при открытии модального окна переноса
window.history.pushState({ modalOpen: true, type: 'task-postpone' }, '', window.location.href)
historyPushedForPostponeRef.current = true
} else if (!selectedTaskForPostpone) {
historyPushedForPostponeRef.current = false
}
if (selectedTaskForDetail && !historyPushedForDetailRef.current) {
// Добавляем запись в историю при открытии модального окна деталей задачи
window.history.pushState({ modalOpen: true, type: 'task-detail' }, '', window.location.href)
historyPushedForDetailRef.current = true
} else if (!selectedTaskForDetail) {
historyPushedForDetailRef.current = false
}
if (!selectedTaskForDetail && !selectedTaskForPostpone) return
const handlePopState = (event) => {
// Проверяем наличие модальных окон в DOM
const taskDetailModal = document.querySelector('.task-detail-modal-overlay')
const postponeModal = document.querySelector('.task-postpone-modal-overlay')
// Используем refs для получения актуального состояния
const currentTaskDetail = selectedTaskForDetailRef.current
const currentPostpone = selectedTaskForPostponeRef.current
// Сначала проверяем модальное окно переноса (если оно открыто поверх)
if (currentPostpone || postponeModal) {
setSelectedTaskForPostpone(null)
setPostponeDate('')
historyPushedForPostponeRef.current = false
// Возвращаем запись для модального окна деталей задачи, если оно было открыто
if (currentTaskDetail || taskDetailModal) {
window.history.pushState({ modalOpen: true, type: 'task-detail' }, '', window.location.href)
}
return
}
// Если открыто модальное окно деталей задачи, закрываем его
if (currentTaskDetail || taskDetailModal) {
setSelectedTaskForDetail(null)
historyPushedForDetailRef.current = false
// Следующее нажатие "назад" обработается App.jsx нормально
return
}
}
window.addEventListener('popstate', handlePopState)
return () => {
window.removeEventListener('popstate', handlePopState)
}
}, [selectedTaskForDetail, selectedTaskForPostpone])
// Функция для вычисления следующей даты по repetition_date
const calculateNextDateFromRepetitionDate = (repetitionDateStr) => {
if (!repetitionDateStr) return null
const parts = repetitionDateStr.trim().split(/\s+/)
if (parts.length < 2) return null
const value = parts[0]
const unit = parts[1].toLowerCase()
const now = new Date()
now.setHours(0, 0, 0, 0)
switch (unit) {
case 'week': {
// N-й день недели (1=понедельник, 7=воскресенье)
const dayOfWeek = parseInt(value, 10)
if (isNaN(dayOfWeek) || dayOfWeek < 1 || dayOfWeek > 7) return null
// JavaScript: 0=воскресенье, 1=понедельник... 6=суббота
// Наш формат: 1=понедельник... 7=воскресенье
// Конвертируем: наш 1 (Пн) -> JS 1, наш 7 (Вс) -> JS 0
const targetJsDay = dayOfWeek === 7 ? 0 : dayOfWeek
const currentJsDay = now.getDay()
// Вычисляем дни до следующего вхождения (включая сегодня, если ещё не прошло)
let daysUntil = (targetJsDay - currentJsDay + 7) % 7
// Если сегодня тот же день, берём следующую неделю
if (daysUntil === 0) daysUntil = 7
const nextDate = new Date(now)
nextDate.setDate(now.getDate() + daysUntil)
return nextDate
}
case 'month': {
// N-й день месяца
const dayOfMonth = parseInt(value, 10)
if (isNaN(dayOfMonth) || dayOfMonth < 1 || dayOfMonth > 31) return null
// Ищем ближайшую дату с этим днём
let searchDate = new Date(now)
for (let i = 0; i < 12; i++) {
const year = searchDate.getFullYear()
const month = searchDate.getMonth()
const lastDayOfMonth = new Date(year, month + 1, 0).getDate()
const actualDay = Math.min(dayOfMonth, lastDayOfMonth)
const candidateDate = new Date(year, month, actualDay)
if (candidateDate > now) {
return candidateDate
}
// Переходим к следующему месяцу
searchDate = new Date(year, month + 1, 1)
}
return null
}
case 'year': {
// MM-DD формат
const dateParts = value.split('-')
if (dateParts.length !== 2) return null
const monthNum = parseInt(dateParts[0], 10)
const day = parseInt(dateParts[1], 10)
if (isNaN(monthNum) || isNaN(day) || monthNum < 1 || monthNum > 12 || day < 1 || day > 31) return null
let year = now.getFullYear()
let candidateDate = new Date(year, monthNum - 1, day)
if (candidateDate <= now) {
candidateDate = new Date(year + 1, monthNum - 1, day)
}
return candidateDate
}
default:
return null
}
}
// Функция для вычисления следующей даты по repetition_period
const calculateNextDateFromRepetitionPeriod = (repetitionPeriodStr) => {
if (!repetitionPeriodStr) return null
const parts = repetitionPeriodStr.trim().split(/\s+/)
if (parts.length < 2) return null
const value = parseInt(parts[0], 10)
if (isNaN(value) || value === 0) return null
const unit = parts[1].toLowerCase()
const now = new Date()
now.setHours(0, 0, 0, 0)
const nextDate = new Date(now)
switch (unit) {
case 'minute':
case 'minutes':
case 'mins':
case 'min':
nextDate.setMinutes(nextDate.getMinutes() + value)
break
case 'hour':
case 'hours':
case 'hrs':
case 'hr':
nextDate.setHours(nextDate.getHours() + value)
break
case 'day':
case 'days':
// PostgreSQL может возвращать недели как дни (например, "7 days" вместо "1 week")
// Если количество дней кратно 7, обрабатываем как недели
if (value % 7 === 0 && value >= 7) {
const weeks = value / 7
nextDate.setDate(nextDate.getDate() + weeks * 7)
} else {
nextDate.setDate(nextDate.getDate() + value)
}
break
case 'week':
case 'weeks':
case 'wks':
case 'wk':
nextDate.setDate(nextDate.getDate() + value * 7)
break
case 'month':
case 'months':
case 'mons':
case 'mon':
nextDate.setMonth(nextDate.getMonth() + value)
break
case 'year':
case 'years':
case 'yrs':
case 'yr':
nextDate.setFullYear(nextDate.getFullYear() + value)
break
default:
return null
}
return nextDate
}
// Форматирование даты в YYYY-MM-DD (локальное время, без смещения в UTC)
const formatDateToLocal = (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 formatDateForDisplay = (dateStr) => {
if (!dateStr) return ''
// Парсим дату из формата YYYY-MM-DD
const dateParts = dateStr.split('-')
if (dateParts.length !== 3) return dateStr
const yearNum = parseInt(dateParts[0], 10)
const monthNum = parseInt(dateParts[1], 10) - 1 // месяцы в JS начинаются с 0
const dayNum = parseInt(dateParts[2], 10)
if (isNaN(yearNum) || isNaN(monthNum) || isNaN(dayNum)) return dateStr
const targetDate = new Date(yearNum, monthNum, dayNum)
targetDate.setHours(0, 0, 0, 0)
const now = new Date()
now.setHours(0, 0, 0, 0)
const diffDays = Math.floor((targetDate - now) / (1000 * 60 * 60 * 24))
// Сегодня
if (diffDays === 0) {
return 'Сегодня'
}
// Завтра
if (diffDays === 1) {
return 'Завтра'
}
// Вчера
if (diffDays === -1) {
return 'Вчера'
}
// Дни недели для ближайших дней из будущего (в пределах 7 дней)
if (diffDays > 0 && diffDays <= 7) {
const dayNames = ['Воскресенье', 'Понедельник', 'Вторник', 'Среда', 'Четверг', 'Пятница', 'Суббота']
const dayOfWeek = targetDate.getDay()
return dayNames[dayOfWeek]
}
const monthNames = ['января', 'февраля', 'марта', 'апреля', 'мая', 'июня',
'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря']
// Если это число из того же года - только день и месяц
if (targetDate.getFullYear() === now.getFullYear()) {
const displayDay = targetDate.getDate()
const displayMonth = monthNames[targetDate.getMonth()]
return `${displayDay} ${displayMonth}`
}
// Для других случаев - полная дата
const displayDay = targetDate.getDate()
const displayMonth = monthNames[targetDate.getMonth()]
const displayYear = targetDate.getFullYear()
return `${displayDay} ${displayMonth} ${displayYear}`
}
const handlePostponeClick = (task, e) => {
e.stopPropagation()
setSelectedTaskForPostpone(task)
// Устанавливаем дату по умолчанию
let defaultDate
const now = new Date()
now.setHours(0, 0, 0, 0)
if (task.repetition_date) {
// Для задач с repetition_date - вычисляем следующую подходящую дату
const nextDate = calculateNextDateFromRepetitionDate(task.repetition_date)
if (nextDate) {
defaultDate = nextDate
}
} else if (task.repetition_period && !isZeroPeriod(task.repetition_period)) {
// Для задач с repetition_period (не нулевым) - вычисляем следующую дату
const nextDate = calculateNextDateFromRepetitionPeriod(task.repetition_period)
if (nextDate) {
defaultDate = nextDate
}
}
if (!defaultDate) {
// Без repetition_date/repetition_period или если не удалось вычислить - завтра
defaultDate = new Date(now)
defaultDate.setDate(defaultDate.getDate() + 1)
}
defaultDate.setHours(0, 0, 0, 0)
setPostponeDate(formatDateToLocal(defaultDate))
}
const handlePostponeSubmit = async () => {
if (!selectedTaskForPostpone || !postponeDate) return
await handlePostponeSubmitWithDate(postponeDate)
}
const handlePostponeClose = () => {
// Если была добавлена запись в историю, удаляем её через history.back()
// Обработчик popstate закроет модальное окно и сбросит флаг
if (historyPushedForPostponeRef.current) {
window.history.back()
} else {
// Если записи не было, просто закрываем модальное окно
setSelectedTaskForPostpone(null)
setPostponeDate('')
}
}
const handleDateSelect = (date) => {
if (!date) return
const formattedDate = formatDateToLocal(date)
setPostponeDate(formattedDate)
if (selectedTaskForPostpone) {
handlePostponeSubmitWithDate(formattedDate)
}
}
const handleDayClick = (day, modifiers) => {
// Обрабатываем клик даже если дата уже выбрана
if (day && !modifiers.disabled) {
handleDateSelect(day)
}
}
const handleTodayClick = () => {
const today = new Date()
today.setHours(0, 0, 0, 0)
setPostponeDate(formatDateToLocal(today))
// Применяем дату сразу
if (selectedTaskForPostpone) {
handlePostponeSubmitWithDate(formatDateToLocal(today))
}
}
const handleTomorrowClick = () => {
const tomorrow = new Date()
tomorrow.setDate(tomorrow.getDate() + 1)
tomorrow.setHours(0, 0, 0, 0)
setPostponeDate(formatDateToLocal(tomorrow))
// Применяем дату сразу
if (selectedTaskForPostpone) {
handlePostponeSubmitWithDate(formatDateToLocal(tomorrow))
}
}
const handlePostponeSubmitWithDate = async (dateToUse) => {
if (!selectedTaskForPostpone || !dateToUse) return
setIsPostponing(true)
try {
// Преобразуем дату в ISO формат с временем
const dateObj = new Date(dateToUse)
dateObj.setHours(0, 0, 0, 0)
const isoDate = dateObj.toISOString()
const response = await authFetch(`${API_URL}/${selectedTaskForPostpone.id}/postpone`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ next_show_at: isoDate }),
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw new Error(errorData.message || 'Ошибка при переносе задачи')
}
// Обновляем список
if (onRefresh) {
onRefresh()
}
// Закрываем модальное окно и удаляем запись из истории, если она была добавлена
// Обработчик popstate закроет модальное окно и сбросит флаг
if (historyPushedForPostponeRef.current) {
window.history.back()
} else {
setSelectedTaskForPostpone(null)
setPostponeDate('')
}
} catch (err) {
console.error('Error postponing task:', err)
setToast({ message: err.message || 'Ошибка при переносе задачи', type: 'error' })
} finally {
setIsPostponing(false)
}
}
const handleWithoutDateClick = async () => {
if (!selectedTaskForPostpone) return
setIsPostponing(true)
try {
const response = await authFetch(`${API_URL}/${selectedTaskForPostpone.id}/postpone`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ next_show_at: null }),
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw new Error(errorData.message || 'Ошибка при переносе задачи')
}
if (onRefresh) {
onRefresh()
}
if (historyPushedForPostponeRef.current) {
window.history.back()
} else {
setSelectedTaskForPostpone(null)
setPostponeDate('')
}
} catch (err) {
console.error('Error postponing task:', err)
setToast({ message: err.message || 'Ошибка при переносе задачи', type: 'error' })
} finally {
setIsPostponing(false)
}
}
const toggleCompletedExpanded = (projectName) => {
setExpandedCompleted(prev => ({
...prev,
[projectName]: !prev[projectName]
}))
}
const handleProgressionChange = async (task, delta) => {
if (savingProgressionTaskId === task.id) return
const currentValue = task.draft_progression_value ?? 0
const newValue = currentValue + delta
setSavingProgressionTaskId(task.id)
try {
const response = await authFetch(`${API_URL}/${task.id}/draft`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
progression_value: newValue
}),
})
if (!response.ok) {
throw new Error('Ошибка при сохранении прогрессии')
}
setTasks(prevTasks =>
prevTasks.map(t =>
t.id === task.id
? { ...t, draft_progression_value: newValue }
: t
)
)
} catch (err) {
console.error('Error saving progression:', err)
setToast({ message: err.message || 'Ошибка при сохранении прогрессии', type: 'error' })
} finally {
setSavingProgressionTaskId(null)
}
}
// Получаем все проекты из задачи (теперь они приходят в task.project_names)
const getTaskProjects = (task) => {
if (task.project_names && Array.isArray(task.project_names)) {
return task.project_names
}
return []
}
// Получаем название группы задачи (для режима группировки по группе)
const getTaskGroupName = (task) => {
// Если у задачи есть group_name - возвращаем его
if (task.group_name && task.group_name.trim()) {
return task.group_name.trim()
}
// Иначе возвращаем null - задача попадёт в "Остальные"
return null
}
// Функция для проверки, является ли период нулевым
const isZeroPeriod = (intervalStr) => {
if (!intervalStr) return false
const trimmed = intervalStr.trim()
// Проверяем формат времени "00:00:00" или "0:00:00"
if (/^\d{1,2}:\d{2}:\d{2}/.test(trimmed)) {
const timeParts = trimmed.split(':')
if (timeParts.length >= 3) {
const hours = parseInt(timeParts[0], 10)
const minutes = parseInt(timeParts[1], 10)
const seconds = parseInt(timeParts[2], 10)
return !isNaN(hours) && !isNaN(minutes) && !isNaN(seconds) &&
hours === 0 && minutes === 0 && seconds === 0
}
}
// PostgreSQL может возвращать "0 day", "0 days", "0", и т.д.
const parts = trimmed.split(/\s+/)
if (parts.length < 1) return false
const value = parseInt(parts[0], 10)
return !isNaN(value) && value === 0
}
// Функция для проверки, является ли repetition_date нулевым
const isZeroDate = (dateStr) => {
if (!dateStr) return false
const trimmed = dateStr.trim()
const parts = trimmed.split(/\s+/)
if (parts.length < 2) return false
const value = parts[0]
// Проверяем, является ли значение "0" (для формата "0 week", "0 month", "0 year")
const numValue = parseInt(value, 10)
return !isNaN(numValue) && numValue === 0
}
// Группируем задачи по проектам или группам
const groupedTasks = useMemo(() => {
const today = new Date()
today.setHours(0, 0, 0, 0)
// Фильтруем задачи по поисковому запросу
const filteredTasks = searchQuery.trim()
? tasks.filter(task =>
task.name.toLowerCase().includes(searchQuery.toLowerCase())
)
: tasks
const groups = {}
filteredTasks.forEach(task => {
let groupKeys = []
if (groupingMode === 'project') {
// Группировка по проекту (текущее поведение)
groupKeys = getTaskProjects(task)
if (groupKeys.length === 0) {
groupKeys = ['Остальные'] // Было 'Без проекта'
}
} else {
// Группировка по group_name
const groupName = getTaskGroupName(task)
groupKeys = groupName ? [groupName] : ['Остальные']
}
// Определяем, в какую группу попадает задача
let isCompleted = false
// Сначала проверяем, является ли задача бесконечной
// Бесконечная задача: repetition_period == 0 И (repetition_date == 0 ИЛИ отсутствует)
// Для обратной совместимости: если repetition_period = 0, считаем бесконечной
const hasZeroPeriod = task.repetition_period && isZeroPeriod(task.repetition_period)
const hasZeroDate = task.repetition_date && isZeroDate(task.repetition_date)
// Идеально: оба поля = 0, но для старых задач может быть только repetition_period = 0
const isInfinite = (hasZeroPeriod && hasZeroDate) || (hasZeroPeriod && !task.repetition_date)
// Бесконечные задачи всегда идут в completed, независимо от next_show_at
if (isInfinite) {
isCompleted = true
} else if (task.next_show_at) {
// Для обычных задач используем next_show_at для группировки
const nextShowDate = new Date(task.next_show_at)
nextShowDate.setHours(0, 0, 0, 0)
isCompleted = nextShowDate.getTime() > today.getTime()
} else {
// Задачи без даты (next_show_at = null) идут в выполненные
isCompleted = true
}
groupKeys.forEach(groupKey => {
if (!groups[groupKey]) {
groups[groupKey] = {
notCompleted: [],
completed: []
}
}
if (isCompleted) {
groups[groupKey].completed.push(task)
} else {
// Бесконечные задачи теперь идут в обычный список
groups[groupKey].notCompleted.push(task)
}
})
})
// Сортируем задачи внутри каждой группы проекта
Object.keys(groups).forEach(projectName => {
const group = groups[projectName]
// Сортируем невыполненные задачи: по completed DESC (больше завершений выше), затем по id ASC (раньше добавленные выше)
group.notCompleted.sort((a, b) => {
if (b.completed !== a.completed) {
return b.completed - a.completed // DESC
}
return a.id - b.id // ASC
})
// Сортируем выполненные задачи: бесконечные первыми, затем по next_show_at ASC (ранние в начале), NULL в начале
group.completed.sort((a, b) => {
// Проверяем, является ли задача бесконечной
const hasZeroPeriodA = a.repetition_period && isZeroPeriod(a.repetition_period)
const hasZeroDateA = a.repetition_date && isZeroDate(a.repetition_date)
const isInfiniteA = (hasZeroPeriodA && hasZeroDateA) || (hasZeroPeriodA && !a.repetition_date)
const hasZeroPeriodB = b.repetition_period && isZeroPeriod(b.repetition_period)
const hasZeroDateB = b.repetition_date && isZeroDate(b.repetition_date)
const isInfiniteB = (hasZeroPeriodB && hasZeroDateB) || (hasZeroPeriodB && !b.repetition_date)
// Бесконечные задачи идут первыми
if (isInfiniteA && !isInfiniteB) return -1
if (!isInfiniteA && isInfiniteB) return 1
if (isInfiniteA && isInfiniteB) return 0
// Для остальных: NULL значения идут последними
if (!a.next_show_at && !b.next_show_at) return 0
if (!a.next_show_at) return 1
if (!b.next_show_at) return -1
// Сравниваем даты
const dateA = new Date(a.next_show_at).getTime()
const dateB = new Date(b.next_show_at).getTime()
return dateA - dateB // ASC
})
})
return groups
}, [tasks, searchQuery, groupingMode])
// Сортируем проекты: сначала с невыполненными задачами, потом без них
// Группа "Без проекта" всегда последняя в своей категории
const projectNames = useMemo(() => {
const sorted = Object.keys(groupedTasks).sort((a, b) => {
const groupA = groupedTasks[a]
const groupB = groupedTasks[b]
const hasNotCompletedA = groupA.notCompleted.length > 0
const hasNotCompletedB = groupB.notCompleted.length > 0
// Если у одной группы есть невыполненные, а у другой нет - сортируем по этому признаку
if (hasNotCompletedA && !hasNotCompletedB) return -1
if (!hasNotCompletedA && hasNotCompletedB) return 1
// Если обе группы в одной категории
const isOthersA = a === 'Остальные'
const isOthersB = b === 'Остальные'
// "Остальные" всегда последняя в своей категории
if (isOthersA && !isOthersB) return 1
if (!isOthersA && isOthersB) return -1
// Остальные группы сортируем по алфавиту
return a.localeCompare(b)
})
return sorted
}, [groupedTasks])
const renderTaskItem = (task, isCompleted = false) => {
const hasProgression = task.has_progression || task.progression_base != null
const hasSubtasks = task.subtasks_count > 0
const isTest = task.config_id != null
const showDetailOnCheckmark = !isTest
const isWishlist = task.wishlist_id != null
// Проверяем бесконечную задачу: repetition_period = 0 И (repetition_date = 0 ИЛИ отсутствует)
// Для обратной совместимости: если repetition_period = 0, считаем бесконечной
const hasZeroPeriod = task.repetition_period && isZeroPeriod(task.repetition_period)
const hasZeroDate = task.repetition_date && isZeroDate(task.repetition_date)
// Бесконечная задача: repetition_period = 0 И (repetition_date = 0 ИЛИ отсутствует)
// Не проверяем next_show_at, так как для бесконечных задач он может быть установлен при выполнении
const isInfinite = (hasZeroPeriod && hasZeroDate) || (hasZeroPeriod && !task.repetition_date)
// Одноразовая задача: когда оба поля null/undefined
const isOneTime = (task.repetition_period == null || task.repetition_period === undefined) &&
(task.repetition_date == null || task.repetition_date === undefined)
return (
<div
key={task.id}
className="task-item"
onClick={() => handleTaskClick(task)}
>
<div className="task-item-content">
<div
className={`task-checkmark ${showDetailOnCheckmark ? 'task-checkmark-detail' : ''} ${task.auto_complete ? 'task-checkmark-auto-complete' : ''}`}
onClick={(e) => handleCheckmarkClick(task, e)}
title={isTest ? 'Запустить тест' : (showDetailOnCheckmark ? 'Открыть детали' : 'Выполнить задачу')}
>
{isTest ? (
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"></path>
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"></path>
</svg>
) : isWishlist ? (
<>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M20 12v10M4 12v10M20 22H4"></path>
<path d="M22 7H2M22 7v5M2 7v5"></path>
<path d="M12 7H7.5a2.5 2.5 0 0 1 0-5C11 2 12 7 12 7z"></path>
<path d="M12 7h4.5a2.5 2.5 0 0 0 0-5C13 2 12 7 12 7z"></path>
</svg>
{task.auto_complete && (
<svg
className="task-checkmark-wishlist-lightning-icon"
width="16"
height="16"
viewBox="0 0 24 24"
fill="currentColor"
title="Задача-желание"
>
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"></path>
</svg>
)}
</>
) : (
<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>
)}
{task.auto_complete && !isTest && !isWishlist && (
<svg
className="task-checkmark-auto-complete-icon"
width="16"
height="16"
viewBox="0 0 24 24"
fill="currentColor"
title="Автовыполнение в конце дня"
>
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"></path>
</svg>
)}
</div>
<div className="task-name-container">
<div className="task-name-wrapper">
<div className="task-name">
{task.name}
{hasSubtasks && (
<span className="task-subtasks-count">(+{task.subtasks_count})</span>
)}
<span className="task-badge-bar">
{!isOneTime && !isInfinite && !isWishlist && (
<svg
className="task-recurring-icon"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
title="Повторяющаяся задача"
>
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/>
<path d="M21 3v5h-5"/>
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/>
<path d="M3 21v-5h5"/>
</svg>
)}
{isInfinite && (
<svg
className="task-infinite-icon"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
title="Бесконечная задача"
>
<path d="M12 12c0-2.5-1.5-4.5-3.5-4.5S5 9.5 5 12s1.5 4.5 3.5 4.5S12 14.5 12 12z"/>
<path d="M12 12c0 2.5 1.5 4.5 3.5 4.5S19 14.5 19 12s-1.5-4.5-3.5-4.5S12 9.5 12 12z"/>
</svg>
)}
{hasProgression && (
<span
className={`task-progression-capsule ${savingProgressionTaskId === task.id ? 'task-progression-capsule--saving' : ''}`}
onClick={(e) => {
e.stopPropagation()
if (savingProgressionTaskId !== task.id) {
handleProgressionChange(task, task.progression_base)
}
}}
title="Задача с прогрессией"
>
<svg
className="task-progression-icon"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="23 6 13.5 15.5 8.5 10.5 1 18"></polyline>
<polyline points="17 6 23 6 23 12"></polyline>
</svg>
{task.draft_progression_value != null && (
<span className="task-progression-value">{task.draft_progression_value}</span>
)}
</span>
)}
</span>
</div>
{/* Показываем дату только для выполненных задач */}
{isCompleted && task.next_show_at && (() => {
const showDate = new Date(task.next_show_at)
// Нормализуем дату: устанавливаем время в 00:00:00 в локальном времени
const showDateNormalized = new Date(showDate.getFullYear(), showDate.getMonth(), showDate.getDate())
const today = new Date()
const todayNormalized = new Date(today.getFullYear(), today.getMonth(), today.getDate())
const tomorrowNormalized = new Date(todayNormalized)
tomorrowNormalized.setDate(tomorrowNormalized.getDate() + 1)
// Не показываем дату если она сегодня или в прошлом
if (showDateNormalized.getTime() <= todayNormalized.getTime()) {
return null
}
let dateText
if (showDateNormalized.getTime() === tomorrowNormalized.getTime()) {
dateText = 'Завтра'
} else {
dateText = showDate.toLocaleDateString('ru-RU', { day: 'numeric', month: 'long', year: 'numeric' })
}
return (
<div className="task-next-show-date">
{dateText}
</div>
)
})()}
</div>
</div>
<div className="task-actions">
<button
className="task-postpone-button"
onClick={(e) => handlePostponeClick(task, e)}
title="Перенести задачу"
>
<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="8" stroke="currentColor" strokeWidth="1.5" fill="none"/>
<path d="M10 5V10L13 12" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" fill="none"/>
</svg>
</button>
</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
// Показываем ошибку загрузки, если есть ошибка и нет данных
if (error && !hasData && !loading) {
return (
<div className="task-list">
<LoadingError onRetry={onRetry} />
</div>
)
}
// Показываем загрузку только если:
// 1. Идет загрузка (loading = true)
// 2. Это не фоновая загрузка (backgroundLoading = false)
// 3. Данных нет (hasData = false)
// Это предотвращает показ загрузки при переключении табов, когда данные уже есть
if (loading && !backgroundLoading && !hasData) {
return (
<div className="task-list">
<div className="fixed inset-0 bottom-20 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>
</div>
)
}
return (
<div className="task-list">
{toast && (
<Toast
message={toast.message}
type={toast.type || 'success'}
onClose={() => setToast(null)}
/>
)}
{/* Поле поиска */}
<div className="task-search-container">
<svg
className="task-search-icon"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="11" cy="11" r="8"></circle>
<path d="m21 21-4.35-4.35"></path>
</svg>
<input
type="text"
className="task-search-input"
placeholder="Поиск задач..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
{/* Кнопка переключения группировки */}
<button
type="button"
className="task-grouping-toggle"
onClick={() => setGroupingMode(prev => prev === 'project' ? 'group' : 'project')}
title={groupingMode === 'project' ? 'Группировка по проекту' : 'Группировка по группе'}
>
{groupingMode === 'project' ? (
// Иконка "папка" для группировки по проекту (filled)
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path>
</svg>
) : (
// Иконка "тег" для группировки по группе (filled, с вырезом под дырку)
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" fillRule="evenodd">
<path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z M7 5.5a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3z"/>
</svg>
)}
</button>
{searchQuery && (
<button
className="task-search-clear"
onClick={() => setSearchQuery('')}
title="Очистить поиск"
>
</button>
)}
</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 hasNotCompleted = group.notCompleted.length > 0
const isCompletedExpanded = expandedCompleted[projectName]
return (
<div key={projectName} className={`project-group ${!hasNotCompleted ? 'project-group-no-tasks' : ''}`}>
<div
className={`project-group-header ${hasCompleted ? 'project-group-header-clickable' : ''}`}
onClick={hasCompleted ? () => toggleCompletedExpanded(projectName) : undefined}
title={hasCompleted ? (isCompletedExpanded ? 'Скрыть выполненные' : 'Показать выполненные') : undefined}
>
<h3 className={`project-group-title ${!hasNotCompleted ? 'project-group-title-empty' : ''}`}>{projectName}</h3>
{hasCompleted ? (
<button
className="completed-toggle-header"
onClick={(e) => {
e.stopPropagation()
toggleCompletedExpanded(projectName)
}}
title={isCompletedExpanded ? 'Скрыть выполненные' : 'Показать выполненные'}
>
<span className="completed-toggle-icon">
{isCompletedExpanded ? '▼' : '▶'}
</span>
</button>
) : (
<div className="completed-toggle-header" style={{ visibility: 'hidden', pointerEvents: 'none' }}>
<span className="completed-toggle-icon"></span>
</div>
)}
</div>
{/* Обычные задачи (включая бесконечные) */}
{group.notCompleted.length > 0 && (
<div className="task-group">
{group.notCompleted.map(task => renderTaskItem(task, false))}
</div>
)}
{/* Выполненные задачи */}
{hasCompleted && isCompletedExpanded && (
<div className="task-group completed-tasks">
{group.completed.map(task => renderTaskItem(task, true))}
</div>
)}
{group.notCompleted.length === 0 && !hasCompleted && (
<div className="empty-group">Нет задач в этой группе</div>
)}
</div>
)
})}
{/* Модальное окно для деталей задачи */}
{selectedTaskForDetail && (
<TaskDetail
taskId={selectedTaskForDetail}
onClose={handleCloseDetail}
onRefresh={onRefresh}
onTaskCompleted={() => setToast({ message: 'Задача выполнена', type: 'success' })}
onNavigate={onNavigate}
/>
)}
{/* Модальное окно для переноса задачи */}
{selectedTaskForPostpone && (() => {
const todayStr = formatDateToLocal(new Date())
const tomorrow = new Date()
tomorrow.setDate(tomorrow.getDate() + 1)
const tomorrowStr = formatDateToLocal(tomorrow)
// Проверяем next_show_at задачи, а не значение в поле ввода
let nextShowAtStr = null
if (selectedTaskForPostpone.next_show_at) {
const nextShowAtDate = new Date(selectedTaskForPostpone.next_show_at)
nextShowAtStr = formatDateToLocal(nextShowAtDate)
}
const isToday = nextShowAtStr === todayStr
const isTomorrow = nextShowAtStr === tomorrowStr
// Не показывать «Сегодня», если next_show_at уже сегодня или в прошлом
const showTodayChip = !nextShowAtStr || nextShowAtStr > todayStr
// Дата "по плану" (repetition_date / repetition_period или завтра)
const task = selectedTaskForPostpone
let plannedDate
const now = new Date()
now.setHours(0, 0, 0, 0)
if (task.repetition_date) {
const nextDate = calculateNextDateFromRepetitionDate(task.repetition_date)
if (nextDate) plannedDate = nextDate
} else if (task.repetition_period && !isZeroPeriod(task.repetition_period)) {
const nextDate = calculateNextDateFromRepetitionPeriod(task.repetition_period)
if (nextDate) plannedDate = nextDate
}
if (!plannedDate) {
plannedDate = new Date(now)
plannedDate.setDate(plannedDate.getDate() + 1)
}
plannedDate.setHours(0, 0, 0, 0)
const plannedDateStr = formatDateToLocal(plannedDate)
const plannedNorm = plannedDateStr.slice(0, 10)
const nextShowNorm = nextShowAtStr ? String(nextShowAtStr).slice(0, 10) : ''
// Показываем кнопку, если текущий next_show_at не совпадает с датой по плану
const isCurrentDatePlanned = plannedNorm && nextShowNorm && plannedNorm === nextShowNorm
const modalContent = (
<div className="task-postpone-modal-overlay" onClick={handlePostponeClose}>
<div className="task-postpone-modal" onClick={(e) => e.stopPropagation()}>
<div className="task-postpone-modal-header">
<h3>{selectedTaskForPostpone.name}</h3>
<button onClick={handlePostponeClose} className="task-postpone-close-button">
</button>
</div>
<div className="task-postpone-modal-content">
<div className="task-postpone-calendar">
<DayPicker
mode="single"
selected={postponeDate ? new Date(postponeDate + 'T00:00:00') : undefined}
onSelect={handleDateSelect}
onDayClick={handleDayClick}
disabled={{ before: (() => {
const today = new Date()
today.setHours(0, 0, 0, 0)
return today
})() }}
locale={ru}
/>
</div>
<div className="task-postpone-quick-buttons">
{showTodayChip && (
<button
onClick={handleTodayClick}
className="task-postpone-quick-button"
disabled={isPostponing}
>
Сегодня
</button>
)}
{!isTomorrow && (
<button
onClick={handleTomorrowClick}
className="task-postpone-quick-button"
disabled={isPostponing}
>
Завтра
</button>
)}
{!isCurrentDatePlanned && (
<button
onClick={() => handlePostponeSubmitWithDate(plannedDateStr)}
className="task-postpone-quick-button"
disabled={isPostponing}
>
По плану
</button>
)}
{selectedTaskForPostpone?.next_show_at && (
<button
onClick={handleWithoutDateClick}
className="task-postpone-quick-button"
disabled={isPostponing}
>
Без даты
</button>
)}
</div>
</div>
</div>
</div>
)
return typeof document !== 'undefined'
? createPortal(modalContent, document.body)
: modalContent
})()}
</div>
)
}
export default TaskList