Bump version to 3.7.0: Add next task date info in task completion dialog
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 34s

This commit is contained in:
poignatov
2026-01-10 19:27:36 +03:00
parent cc7c6a905e
commit dde8858d7d
4 changed files with 253 additions and 19 deletions

View File

@@ -1 +1 @@
3.6.0 3.7.0

View File

@@ -1,6 +1,6 @@
{ {
"name": "play-life-web", "name": "play-life-web",
"version": "3.6.0", "version": "3.7.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

View File

@@ -189,6 +189,12 @@
} }
.task-actions-section { .task-actions-section {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.task-actions-buttons {
display: flex; display: flex;
gap: 0.75rem; gap: 0.75rem;
align-items: center; align-items: center;
@@ -248,6 +254,14 @@
cursor: not-allowed; 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, .loading,
.error-message { .error-message {
text-align: center; text-align: center;

View File

@@ -25,6 +25,196 @@ const isZeroDate = (dateStr) => {
return !isNaN(numValue) && numValue === 0 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 значащих цифр) // Функция для форматирования числа как %.4g в Go (до 4 значащих цифр)
const formatScore = (num) => { const formatScore = (num) => {
if (num === 0) return '0' 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) && const isOneTime = (task?.repetition_period == null || task?.repetition_period === undefined) &&
(task?.repetition_date == null || task?.repetition_date === 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 в реальном времени // Формируем сообщение для Telegram в реальном времени
const telegramMessage = useMemo(() => { const telegramMessage = useMemo(() => {
if (!taskDetail) return '' if (!taskDetail) return ''
@@ -380,28 +593,35 @@ function TaskDetail({ taskId, onClose, onRefresh, onTaskCompleted }) {
{/* Кнопки действий */} {/* Кнопки действий */}
<div className="task-actions-section"> <div className="task-actions-section">
<button <div className="task-actions-buttons">
onClick={() => handleComplete(false)}
disabled={isCompleting || !canComplete}
className="complete-button"
>
<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 && (
<button <button
onClick={() => handleComplete(true)} onClick={() => handleComplete(false)}
disabled={isCompleting || !canComplete} disabled={isCompleting || !canComplete}
className="close-button-outline" className="complete-button"
title="Выполнить и закрыть"
> >
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/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="M3 7L7 11L15 3" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/> <path d="M13.5 4L6 11.5L2.5 8" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M3 11L7 15L15 7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg> </svg>
{isCompleting ? 'Выполнение...' : 'Выполнить'}
</button> </button>
{!isOneTime && (
<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>
</> </>