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
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 34s
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
Reference in New Issue
Block a user