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 (
Задач пока нет. Добавьте задачу через кнопку "Добавить".