diff --git a/VERSION b/VERSION index 40c341b..7c69a55 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -3.6.0 +3.7.0 diff --git a/play-life-web/package.json b/play-life-web/package.json index 9249577..0bdced7 100644 --- a/play-life-web/package.json +++ b/play-life-web/package.json @@ -1,6 +1,6 @@ { "name": "play-life-web", - "version": "3.6.0", + "version": "3.7.0", "type": "module", "scripts": { "dev": "vite", diff --git a/play-life-web/src/components/TaskDetail.css b/play-life-web/src/components/TaskDetail.css index 2396d80..4e3410a 100644 --- a/play-life-web/src/components/TaskDetail.css +++ b/play-life-web/src/components/TaskDetail.css @@ -189,6 +189,12 @@ } .task-actions-section { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.task-actions-buttons { display: flex; gap: 0.75rem; align-items: center; @@ -248,6 +254,14 @@ cursor: not-allowed; } +.next-task-date-info { + font-size: 0.875rem; + color: #6b7280; + text-align: left; + margin-top: -0.125rem; + margin-bottom: -0.5rem; +} + .loading, .error-message { text-align: center; diff --git a/play-life-web/src/components/TaskDetail.jsx b/play-life-web/src/components/TaskDetail.jsx index 6b37542..586f9a1 100644 --- a/play-life-web/src/components/TaskDetail.jsx +++ b/play-life-web/src/components/TaskDetail.jsx @@ -25,6 +25,196 @@ const isZeroDate = (dateStr) => { 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 +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': + nextDate.setMinutes(nextDate.getMinutes() + value) + break + case 'hour': + case 'hours': + nextDate.setHours(nextDate.getHours() + value) + break + case 'day': + case 'days': + nextDate.setDate(nextDate.getDate() + value) + break + case 'week': + case 'weeks': + nextDate.setDate(nextDate.getDate() + value * 7) + break + case 'month': + case 'months': + nextDate.setMonth(nextDate.getMonth() + value) + break + case 'year': + case 'years': + 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' @@ -293,6 +483,29 @@ function TaskDetail({ taskId, onClose, onRefresh, onTaskCompleted }) { 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 '' @@ -380,28 +593,35 @@ function TaskDetail({ taskId, onClose, onRefresh, onTaskCompleted }) { {/* Кнопки действий */}
- - {!isOneTime && ( +
+ {!isOneTime && ( + + )} +
+ {!isOneTime && nextTaskDate && ( +
+ Следующая: {nextTaskDate} +
)}