import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react' import { createPortal } from 'react-dom' import { useAuth } from './auth/AuthContext' import LoadingError from './LoadingError' import Toast from './Toast' import './TaskDetail.css' const API_URL = '/api/tasks' // Функция для проверки, является ли период нулевым const isZeroPeriod = (intervalStr) => { if (!intervalStr) return false const trimmed = intervalStr.trim() 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] const numValue = parseInt(value, 10) return !isNaN(numValue) && numValue === 0 } // Функция для вычисления следующей даты по 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 // Поддерживает сокращенные формы единиц времени (например, "mons" для месяцев) 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}` } // Функция для форматирования числа как %.4g в Go (до 4 значащих цифр) const formatScore = (num) => { if (num === 0) return '0' // Используем toPrecision(4) для получения до 4 значащих цифр let str = num.toPrecision(4) // Убираем лишние нули в конце (но оставляем точку если есть цифры после неё) str = str.replace(/\.?0+$/, '') // Если получилась экспоненциальная нотация для больших чисел, конвертируем обратно if (str.includes('e+') || str.includes('e-')) { const numValue = parseFloat(str) // Для чисел >= 10000 используем экспоненциальную нотацию if (Math.abs(numValue) >= 10000) { return str } // Для остальных конвертируем в обычное число return numValue.toString().replace(/\.?0+$/, '') } return str } // Функция для формирования сообщения Telegram в реальном времени const formatTelegramMessage = (task, rewards, subtasks, selectedSubtasks, progressionValue) => { if (!task) return '' // Вычисляем score для каждой награды основной задачи const rewardStrings = {} const progressionBase = task.progression_base const hasProgression = progressionBase != null // Если прогрессия не введена - используем progression_base const value = progressionValue && progressionValue.trim() !== '' ? parseFloat(progressionValue) : (hasProgression ? progressionBase : null) rewards.forEach(reward => { let score = reward.value if (reward.use_progression && hasProgression) { if (value !== null && !isNaN(value)) { score = (value / progressionBase) * reward.value } else { // Если прогрессия не введена, используем progression_base (score = reward.value) score = reward.value } } const scoreStr = score >= 0 ? `**${reward.project_name}+${formatScore(score)}**` : `**${reward.project_name}-${formatScore(Math.abs(score))}**` rewardStrings[reward.position] = scoreStr }) // Функция для замены плейсхолдеров const replacePlaceholders = (message, rewardStrings) => { let result = message // Сначала защищаем экранированные плейсхолдеры const escapedMarkers = {} for (let i = 0; i < 100; i++) { const escaped = `\\$${i}` const marker = `__ESCAPED_DOLLAR_${i}__` if (result.includes(escaped)) { escapedMarkers[marker] = escaped result = result.replace(new RegExp(escaped.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), marker) } } // Заменяем ${0}, ${1}, и т.д. for (let i = 0; i < 100; i++) { const placeholder = `\${${i}}` if (rewardStrings[i]) { result = result.replace(new RegExp(placeholder.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), rewardStrings[i]) } } // Заменяем $0, $1, и т.д. (с конца, чтобы не заменить $1 в $10) for (let i = 99; i >= 0; i--) { if (rewardStrings[i]) { const searchStr = `$${i}` const regex = new RegExp(`\\$${i}(?!\\d)`, 'g') result = result.replace(regex, rewardStrings[i]) } } // Восстанавливаем экранированные Object.entries(escapedMarkers).forEach(([marker, escaped]) => { result = result.replace(new RegExp(marker, 'g'), escaped) }) return result } // Формируем сообщение основной задачи let mainTaskMessage = task.reward_message && task.reward_message.trim() !== '' ? replacePlaceholders(task.reward_message, rewardStrings) : task.name // Формируем сообщения подзадач const subtaskMessages = [] subtasks.forEach(subtask => { if (!selectedSubtasks.has(subtask.task.id)) return if (!subtask.task.reward_message || subtask.task.reward_message.trim() === '') return // Вычисляем score для наград подзадачи const subtaskRewardStrings = {} subtask.rewards.forEach(reward => { let score = reward.value const subtaskProgressionBase = subtask.task.progression_base if (reward.use_progression) { if (subtaskProgressionBase != null && value !== null && !isNaN(value)) { score = (value / subtaskProgressionBase) * reward.value } else if (hasProgression && value !== null && !isNaN(value)) { score = (value / progressionBase) * reward.value } else if (subtaskProgressionBase != null) { // Если прогрессия не введена, используем progression_base подзадачи (score = reward.value) score = reward.value } else if (hasProgression) { // Если у подзадачи нет progression_base, используем основной (score = reward.value) score = reward.value } } const scoreStr = score >= 0 ? `**${reward.project_name}+${formatScore(score)}**` : `**${reward.project_name}-${formatScore(Math.abs(score))}**` subtaskRewardStrings[reward.position] = scoreStr }) const subtaskMessage = replacePlaceholders(subtask.task.reward_message, subtaskRewardStrings) subtaskMessages.push(subtaskMessage) }) // Формируем итоговое сообщение let finalMessage = mainTaskMessage subtaskMessages.forEach(subtaskMsg => { finalMessage += '\n + ' + subtaskMsg }) return finalMessage } function TaskDetail({ taskId, onClose, onRefresh, onTaskCompleted, onNavigate }) { const { authFetch } = useAuth() const [taskDetail, setTaskDetail] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [selectedSubtasks, setSelectedSubtasks] = useState(new Set()) const [progressionValue, setProgressionValue] = useState('') const [isCompleting, setIsCompleting] = useState(false) const [toastMessage, setToastMessage] = useState(null) const [wishlistInfo, setWishlistInfo] = useState(null) const [isSaving, setIsSaving] = useState(false) const [completeAtEndOfDay, setCompleteAtEndOfDay] = useState(false) const fetchTaskDetail = useCallback(async () => { try { setLoading(true) setError(null) const response = await authFetch(`${API_URL}/${taskId}`) if (!response.ok) { throw new Error('Ошибка загрузки задачи') } const data = await response.json() setTaskDetail(data) // Используем информацию о wishlist из ответа API if (data.wishlist_info) { setWishlistInfo({ id: data.wishlist_info.id, name: data.wishlist_info.name, unlocked: data.wishlist_info.unlocked || false }) } else { setWishlistInfo(null) } // Предзаполнение данных из драфта if (data.draft_progression_value != null) { setProgressionValue(data.draft_progression_value.toString()) } if (data.draft_subtasks && data.draft_subtasks.length > 0) { // Создаем Set из ID подзадач из драфта const draftSubtaskIDs = new Set(data.draft_subtasks.map(ds => ds.subtask_id)) // Фильтруем только те подзадачи, которые существуют в текущих подзадачах задачи const validSubtaskIDs = new Set() if (data.subtasks) { data.subtasks.forEach(subtask => { if (draftSubtaskIDs.has(subtask.task.id)) { validSubtaskIDs.add(subtask.task.id) } }) } setSelectedSubtasks(validSubtaskIDs) } // Значение чекбокса будет установлено в useEffect при изменении taskDetail } catch (err) { setError(err.message) console.error('Error fetching task detail:', err) } finally { setLoading(false) } }, [taskId, authFetch]) useEffect(() => { if (taskId) { fetchTaskDetail() } else { // Сбрасываем состояние при закрытии модального окна setTaskDetail(null) setLoading(true) setError(null) setSelectedSubtasks(new Set()) setProgressionValue('') setCompleteAtEndOfDay(false) } }, [taskId, fetchTaskDetail]) const handleSubtaskToggle = (subtaskId) => { setSelectedSubtasks(prev => { const newSet = new Set(prev) if (newSet.has(subtaskId)) { newSet.delete(subtaskId) } else { newSet.add(subtaskId) } return newSet }) } const handleSave = async () => { if (!taskDetail) return // Если чекбокс включен - выполняем в конце дня, иначе сохраняем без автовыполнения const autoComplete = completeAtEndOfDay setIsSaving(true) try { const payload = { auto_complete: autoComplete, children_task_ids: Array.from(selectedSubtasks) } // Если есть прогрессия, отправляем значение (или сбрасываем, если не введено) if (taskDetail.task.progression_base != null) { if (progressionValue.trim()) { const parsedValue = parseFloat(progressionValue) if (isNaN(parsedValue)) { throw new Error('Неверное значение') } payload.progression_value = parsedValue } else if (!autoComplete) { // Если прогрессия не введена и нет авто-выполнения - сбрасываем в null // При авто-выполнении бэкенд сам подставит progression_base payload.clear_progression_value = true } } else { // Если нет progression_base, но пользователь ввел значение - отправляем его if (progressionValue.trim()) { const parsedValue = parseFloat(progressionValue) if (!isNaN(parsedValue)) { payload.progression_value = parsedValue } } } const endpoint = autoComplete ? `${API_URL}/${taskId}/complete-at-end-of-day` : `${API_URL}/${taskId}/draft` const response = await authFetch(endpoint, { method: autoComplete ? 'POST' : 'PUT', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(payload), }) if (!response.ok) { const errorData = await response.json().catch(() => ({})) throw new Error(errorData.message || 'Ошибка при сохранении драфта') } setToastMessage({ text: autoComplete ? 'Задача будет выполнена в конце дня' : 'Драфт сохранен', type: 'success' }) // Обновляем данные задачи, чтобы получить актуальное значение auto_complete await fetchTaskDetail() // Обновляем данные задачи в списке if (onRefresh) { onRefresh() } // Закрываем модальное окно после успешного сохранения if (onClose) { onClose() } } catch (err) { console.error('Error saving draft:', err) setToastMessage({ text: err.message || 'Ошибка при сохранении драфта', type: 'error' }) } finally { setIsSaving(false) } } const handleComplete = async () => { if (!taskDetail) return // Проверяем, что желание разблокировано (если есть связанное желание) if (wishlistInfo && !wishlistInfo.unlocked) { setToastMessage({ text: 'Невозможно выполнить задачу: желание не разблокировано', type: 'error' }) return } setIsCompleting(true) try { const payload = { children_task_ids: Array.from(selectedSubtasks) } // Если есть прогрессия, отправляем значение (или progression_base, если не введено) if (taskDetail.task.progression_base != null) { if (progressionValue.trim()) { payload.value = parseFloat(progressionValue) if (isNaN(payload.value)) { throw new Error('Неверное значение') } } else { // Если прогрессия не введена - используем progression_base payload.value = taskDetail.task.progression_base } } const endpoint = `${API_URL}/${taskId}/complete` const response = await authFetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(payload), }) if (!response.ok) { const errorData = await response.json().catch(() => ({})) throw new Error(errorData.message || 'Ошибка при выполнении задачи') } // Показываем уведомление о выполнении if (onTaskCompleted) { onTaskCompleted() } // Обновляем список и закрываем модальное окно if (onRefresh) { onRefresh() } if (onClose) { onClose() } } catch (err) { console.error('Error completing task:', err) setToastMessage({ text: err.message || 'Ошибка при выполнении задачи', type: 'error' }) } finally { setIsCompleting(false) } } const handleCompleteFinally = async () => { if (!taskDetail) return // Проверяем, что желание разблокировано (если есть связанное желание) if (wishlistInfo && !wishlistInfo.unlocked) { setToastMessage({ text: 'Невозможно выполнить задачу: желание не разблокировано', type: 'error' }) return } setIsCompleting(true) try { const payload = { children_task_ids: Array.from(selectedSubtasks) } // Если есть прогрессия, отправляем значение (или progression_base, если не введено) if (taskDetail.task.progression_base != null) { if (progressionValue.trim()) { payload.value = parseFloat(progressionValue) if (isNaN(payload.value)) { throw new Error('Неверное значение') } } else { // Если прогрессия не введена - используем progression_base payload.value = taskDetail.task.progression_base } } const endpoint = `${API_URL}/${taskId}/complete-and-delete` const response = await authFetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(payload), }) if (!response.ok) { const errorData = await response.json().catch(() => ({})) throw new Error(errorData.message || 'Ошибка при выполнении задачи') } // Показываем уведомление о выполнении if (onTaskCompleted) { onTaskCompleted() } // Обновляем список и закрываем модальное окно if (onRefresh) { onRefresh() } if (onClose) { onClose() } } catch (err) { console.error('Error completing task:', err) setToastMessage({ text: err.message || 'Ошибка при выполнении задачи', type: 'error' }) } finally { setIsCompleting(false) } } if (!taskId) return null const { task, rewards, subtasks } = taskDetail || {} const hasProgression = task?.progression_base != null // Кнопка активна только если желание разблокировано (или задачи нет связанного желания) const canComplete = !wishlistInfo || wishlistInfo.unlocked const hasProgressionOrSubtasks = hasProgression || (subtasks && subtasks.length > 0) // Определяем, является ли задача одноразовой // Одноразовая задача: когда оба поля null/undefined (из бэкенда видно, что в этом случае задача помечается как deleted) // Бесконечная задача: когда хотя бы одно поле равно "0 day" или "0 week" и т.д. // Повторяющаяся задача: когда есть значение (не null и не 0) // Кнопка "Закрыть" показывается для задач, которые НЕ одноразовые (имеют повторение, даже если оно равно 0) // Проверяем, что оба поля отсутствуют (null или undefined) const isOneTime = (task?.repetition_period == null || task?.repetition_period === undefined) && (task?.repetition_date == null || task?.repetition_date === undefined) // Вычисляем следующую дату для неодноразовых задач const nextTaskDate = useMemo(() => { if (!task || isOneTime) return null const now = new Date() now.setHours(0, 0, 0, 0) let nextDate = null if (task.repetition_date) { // Для задач с repetition_date - вычисляем следующую подходящую дату nextDate = calculateNextDateFromRepetitionDate(task.repetition_date) } else if (task.repetition_period && !isZeroPeriod(task.repetition_period)) { // Для задач с repetition_period (не нулевым) - вычисляем следующую дату nextDate = calculateNextDateFromRepetitionPeriod(task.repetition_period) } if (!nextDate) return null nextDate.setHours(0, 0, 0, 0) return formatDateForDisplay(formatDateToLocal(nextDate)) }, [task, isOneTime]) // Формируем сообщение для Telegram в реальном времени const telegramMessage = useMemo(() => { if (!taskDetail) return '' return formatTelegramMessage(task, rewards || [], subtasks || [], selectedSubtasks, progressionValue) }, [taskDetail, task, rewards, subtasks, selectedSubtasks, progressionValue]) // Обновляем значение чекбокса при изменении taskDetail useEffect(() => { if (taskDetail && taskDetail.task) { const autoCompleteValue = Boolean(taskDetail.task.auto_complete) console.log('useEffect: Updating completeAtEndOfDay from taskDetail:', autoCompleteValue, 'task.auto_complete:', taskDetail.task.auto_complete) setCompleteAtEndOfDay(autoCompleteValue) } else { setCompleteAtEndOfDay(false) } }, [taskDetail]) const modalContent = (