Files
play-life/play-life-web/src/components/TaskDetail.jsx
poignatov 72a6a3caf9
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 58s
Добавлена связь задач с желаниями
2026-01-12 18:58:52 +03:00

725 lines
29 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, useCallback, useMemo } from 'react'
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 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)
// Загружаем информацию о связанном желании, если есть
if (data.task.wishlist_id) {
try {
const wishlistResponse = await authFetch(`/api/wishlist/${data.task.wishlist_id}`)
if (wishlistResponse.ok) {
const wishlistData = await wishlistResponse.json()
setWishlistInfo({
id: wishlistData.id,
name: wishlistData.name,
unlocked: wishlistData.unlocked || false
})
}
} catch (err) {
console.error('Error loading wishlist info:', err)
}
} else {
setWishlistInfo(null)
}
} 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('')
}
}, [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 handleComplete = async (shouldDelete = false) => {
if (!taskDetail) return
// Проверяем, что желание разблокировано (если есть связанное желание)
if (wishlistInfo && !wishlistInfo.unlocked) {
setToastMessage({ text: 'Невозможно выполнить задачу: желание не разблокировано', type: 'error' })
return
}
// Если прогрессия не введена, используем 0 (валидация не требуется)
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 = shouldDelete
? `${API_URL}/${taskId}/complete-and-delete`
: `${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)
}
}
if (!taskId) return null
const { task, rewards, subtasks } = taskDetail || {}
const hasProgression = task?.progression_base != null
// Кнопка активна только если желание разблокировано (или задачи нет связанного желания)
const canComplete = !wishlistInfo || wishlistInfo.unlocked
// Определяем, является ли задача одноразовой
// Одноразовая задача: когда оба поля 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])
return (
<div className="task-detail-modal-overlay" onClick={onClose}>
<div className="task-detail-modal" onClick={(e) => e.stopPropagation()}>
<div className="task-detail-modal-header">
<h2 className="task-detail-title">
{loading ? 'Загрузка...' : error ? 'Ошибка' : taskDetail ? task.name : 'Задача'}
</h2>
<button onClick={onClose} className="task-detail-close-button">
</button>
</div>
<div className="task-detail-modal-content">
{loading && (
<div className="loading">Загрузка...</div>
)}
{error && !loading && (
<LoadingError onRetry={fetchTaskDetail} />
)}
{!loading && !error && taskDetail && (
<>
{/* Информация о связанном желании */}
{task.wishlist_id && wishlistInfo && (
<div className="task-wishlist-link">
<div className="task-wishlist-link-info">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 12 20 22 4 22 4 12"></polyline>
<rect x="2" y="7" width="20" height="5"></rect>
<line x1="12" y1="22" x2="12" y2="7"></line>
<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>
<span className="task-wishlist-link-label">Связано с желанием:</span>
<button
onClick={() => {
if (onClose) onClose()
if (onNavigate && wishlistInfo) {
onNavigate('wishlist-detail', { wishlistId: wishlistInfo.id })
}
}}
className="task-wishlist-link-button"
>
{wishlistInfo.name}
</button>
</div>
</div>
)}
{/* Поле ввода прогрессии */}
{hasProgression && (
<div className="progression-section">
<label className="progression-label">Значение прогрессии</label>
<input
type="number"
step="any"
value={progressionValue}
onChange={(e) => setProgressionValue(e.target.value)}
placeholder={task.progression_base?.toString() || ''}
className="progression-input"
/>
</div>
)}
{/* Список подзадач */}
{subtasks && subtasks.length > 0 && (
<div className="task-subtasks">
{subtasks.map((subtask) => {
const subtaskName = subtask.task.name || 'Подзадача'
return (
<div key={subtask.task.id} className="subtask-item">
<label className="subtask-checkbox-label">
<input
type="checkbox"
checked={selectedSubtasks.has(subtask.task.id)}
onChange={() => handleSubtaskToggle(subtask.task.id)}
className="subtask-checkbox"
/>
<div className="subtask-content">
<div className="subtask-name">{subtaskName}</div>
</div>
</label>
</div>
)
})}
</div>
)}
{/* Разделитель - показываем только если есть прогрессия или подзадачи */}
{(hasProgression || (subtasks && subtasks.length > 0)) && (
<div className="task-detail-divider"></div>
)}
{/* Сообщение награды - показываем только если есть прогрессия или подзадачи */}
{(hasProgression || (subtasks && subtasks.length > 0)) && (
<div className="telegram-message-preview">
<div className="telegram-message-label">Сообщение награды:</div>
<div className="telegram-message-text" dangerouslySetInnerHTML={{
__html: telegramMessage
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
.replace(/\n/g, '<br>')
}} />
</div>
)}
{/* Кнопки действий */}
<div className="task-actions-section">
<div className="task-actions-buttons">
<button
onClick={() => handleComplete(false)}
disabled={isCompleting || !canComplete}
className="complete-button"
title={!canComplete && wishlistInfo ? 'Желание не разблокировано' : ''}
>
{!canComplete && wishlistInfo ? (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ marginRight: '0.5rem' }}>
<path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zM9 6c0-1.66 1.34-3 3-3s3 1.34 3 3v2H9V6zm9 14H6V10h12v10zm-6-3c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2z"/>
</svg>
) : (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style={{ marginRight: '0.5rem' }}>
<path d="M13.5 4L6 11.5L2.5 8" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
)}
{isCompleting ? 'Выполнение...' : 'Выполнить'}
</button>
{!isOneTime && canComplete && (
<button
onClick={() => handleComplete(true)}
disabled={isCompleting || !canComplete}
className="close-button-outline"
title="Выполнить и закрыть"
>
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 7L7 11L15 3" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M3 11L7 15L15 7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</button>
)}
</div>
{!isOneTime && nextTaskDate && (
<div className="next-task-date-info">
Следующая: {nextTaskDate}
</div>
)}
</div>
</>
)}
</div>
</div>
{toastMessage && (
<Toast
message={toastMessage.text}
type={toastMessage.type}
onClose={() => setToastMessage(null)}
/>
)}
</div>
)
}
export default TaskDetail