v2.9.0: Улучшения экрана списка задач - оптимизация загрузки, toast уведомления, исправление центрирования
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 44s

This commit is contained in:
poignatov
2026-01-04 19:37:59 +03:00
parent 6d7d59d2ae
commit 79430ba7f0
8 changed files with 1023 additions and 7 deletions

View File

@@ -0,0 +1,193 @@
/* Модальное окно */
.task-detail-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
}
.task-detail-modal {
background: white;
border-radius: 0.5rem;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
max-width: 400px;
width: 100%;
max-height: 90vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.task-detail-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.5rem;
}
.task-detail-close-button {
background: none;
border: none;
font-size: 1.5rem;
color: #6b7280;
cursor: pointer;
padding: 0;
width: 2rem;
height: 2rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 0.25rem;
transition: all 0.2s;
}
.task-detail-close-button:hover {
background: #f3f4f6;
color: #1f2937;
}
.task-detail-modal-content {
padding: 0 1.5rem 1.5rem 1.5rem;
overflow-y: auto;
flex: 1;
}
.task-detail-title {
font-size: 1.25rem;
font-weight: 600;
color: #1f2937;
margin: 0;
}
.task-reward-message {
margin-bottom: 2rem;
padding: 1rem;
background: #f9fafb;
border-radius: 0.375rem;
border-left: 3px solid #6366f1;
}
.reward-message-text {
color: #374151;
line-height: 1.6;
}
.reward-message-text strong {
color: #1f2937;
font-weight: 600;
}
.task-subtasks {
margin-bottom: 1rem;
}
.subtasks-title {
font-size: 1rem;
font-weight: 600;
color: #1f2937;
margin: 0 0 1rem 0;
}
.subtask-item {
margin-bottom: 0.5rem;
}
.subtask-checkbox-label {
display: flex;
align-items: center;
gap: 0.75rem;
cursor: pointer;
}
.subtask-checkbox {
flex-shrink: 0;
width: 1.25rem;
height: 1.25rem;
cursor: pointer;
}
.subtask-content {
flex: 1;
}
.subtask-name {
font-weight: 500;
color: #1f2937;
}
.subtask-reward-message {
margin-top: 0.5rem;
padding: 0.75rem;
background: white;
border-radius: 0.25rem;
}
.task-complete-section {
margin-top: 1rem;
}
.progression-input-group {
display: flex;
gap: 0.5rem;
align-items: center;
}
.progression-input {
flex: 1;
padding: 0.75rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
font-size: 1rem;
}
.progression-input:focus {
outline: none;
border-color: #6366f1;
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
}
.complete-button {
padding: 0.75rem 1.5rem;
background: linear-gradient(to right, #6366f1, #8b5cf6);
color: white;
border: none;
border-radius: 0.375rem;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
min-width: 3rem;
}
.complete-button.full-width {
width: 100%;
}
.complete-button:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3);
}
.complete-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.loading,
.error-message {
text-align: center;
padding: 3rem 1rem;
color: #6b7280;
}
.error-message {
color: #ef4444;
}

View File

@@ -0,0 +1,206 @@
import React, { useState, useEffect, useCallback } from 'react'
import { useAuth } from './auth/AuthContext'
import './TaskDetail.css'
const API_URL = '/api/tasks'
function TaskDetail({ taskId, onClose, onRefresh, onTaskCompleted }) {
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 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)
} 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 () => {
if (!taskDetail) return
// Валидация: если progression_base != null, то value обязателен
if (taskDetail.task.progression_base != null && !progressionValue.trim()) {
alert('Поле "Значение" обязательно для задач с прогрессией')
return
}
setIsCompleting(true)
try {
const payload = {
children_task_ids: Array.from(selectedSubtasks)
}
if (taskDetail.task.progression_base != null && progressionValue.trim()) {
payload.value = parseFloat(progressionValue)
if (isNaN(payload.value)) {
throw new Error('Неверное значение')
}
}
const response = await authFetch(`${API_URL}/${taskId}/complete`, {
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)
alert(err.message || 'Ошибка при выполнении задачи')
} finally {
setIsCompleting(false)
}
}
if (!taskId) return null
const { task, rewards, subtasks } = taskDetail || {}
const hasProgression = task?.progression_base != null
const canComplete = !hasProgression || (hasProgression && progressionValue.trim())
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 && (
<div className="error-message">{error}</div>
)}
{!loading && !error && taskDetail && (
<>
{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>
)}
<div className="task-complete-section">
{hasProgression ? (
<div className="progression-input-group">
<input
type="number"
step="any"
value={progressionValue}
onChange={(e) => setProgressionValue(e.target.value)}
placeholder={`Значение (~${task.progression_base})`}
className="progression-input"
/>
{progressionValue.trim() && (
<button
onClick={handleComplete}
disabled={isCompleting}
className="complete-button"
>
{isCompleting ? 'Выполнение...' : '✓'}
</button>
)}
</div>
) : (
<button
onClick={handleComplete}
disabled={isCompleting || !canComplete}
className="complete-button full-width"
>
{isCompleting ? 'Выполнение...' : 'Выполнить'}
</button>
)}
</div>
</>
)}
</div>
</div>
</div>
)
}
export default TaskDetail

View File

@@ -0,0 +1,448 @@
import React, { useState, useEffect, useMemo, useRef } from 'react'
import { useAuth } from './auth/AuthContext'
import TaskDetail from './TaskDetail'
import Toast from './Toast'
import './TaskList.css'
const API_URL = '/api/tasks'
function TaskList({ onNavigate, data, loading, backgroundLoading, onRefresh }) {
const { authFetch } = useAuth()
// Инициализируем tasks из data, если data есть, иначе пустой массив
const [tasks, setTasks] = useState(() => data && Array.isArray(data) ? data : [])
const [taskDetails, setTaskDetails] = useState({})
const [loadingDetails, setLoadingDetails] = useState(false)
const [selectedTaskForDetail, setSelectedTaskForDetail] = useState(null)
const [isCompleting, setIsCompleting] = useState(false)
const [expandedCompleted, setExpandedCompleted] = useState({})
const [toast, setToast] = useState(null)
// Для отслеживания изменений в списке задач (чтобы не перезагружать детали без необходимости)
const lastTaskIdsRef = useRef('')
useEffect(() => {
if (data) {
setTasks(data)
}
}, [data])
// Загрузка данных управляется из App.jsx через loadTabData
// TaskList не инициирует загрузку самостоятельно
// Загружаем детали для всех задач
// Оптимизация: загружаем только если список задач изменился (по id и last_completed_at)
useEffect(() => {
if (!tasks || tasks.length === 0) {
setTaskDetails({})
lastTaskIdsRef.current = ''
return
}
// Создаем ключ из id и last_completed_at всех задач
const taskKey = tasks.map(t => `${t.id}:${t.last_completed_at || ''}`).sort().join(',')
// Если ключ не изменился, не перезагружаем детали
if (taskKey === lastTaskIdsRef.current) {
return
}
lastTaskIdsRef.current = taskKey
const loadTaskDetails = async () => {
// Не показываем индикатор загрузки если детали уже есть (фоновое обновление)
const hasExistingDetails = Object.keys(taskDetails).length > 0
if (!hasExistingDetails) {
setLoadingDetails(true)
}
try {
const detailPromises = tasks.map(async (task) => {
try {
const response = await authFetch(`${API_URL}/${task.id}`)
if (response.ok) {
const detail = await response.json()
return { taskId: task.id, detail }
}
} catch (err) {
console.error(`Error loading task detail for ${task.id}:`, err)
}
return null
})
const details = await Promise.all(detailPromises)
const detailsMap = {}
details.forEach(item => {
if (item) {
detailsMap[item.taskId] = item.detail
}
})
setTaskDetails(detailsMap)
} catch (err) {
console.error('Error loading task details:', err)
} finally {
setLoadingDetails(false)
}
}
loadTaskDetails()
}, [tasks, authFetch])
const handleTaskClick = (task) => {
onNavigate?.('task-form', { taskId: task.id })
}
const handleCheckmarkClick = async (task, e) => {
e.stopPropagation()
const detail = taskDetails[task.id]
const hasProgression = detail?.task?.progression_base != null
const hasSubtasks = detail?.subtasks && detail.subtasks.length > 0
if (hasProgression || hasSubtasks) {
// Открываем экран details
setSelectedTaskForDetail(task.id)
} else {
// Отправляем задачу
setIsCompleting(true)
try {
const response = await authFetch(`${API_URL}/${task.id}/complete`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({}),
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw new Error(errorData.message || 'Ошибка при выполнении задачи')
}
// Показываем toast о выполнении задачи
setToast({ message: 'Задача выполнена' })
// Обновляем список
if (onRefresh) {
onRefresh()
}
} catch (err) {
console.error('Error completing task:', err)
alert(err.message || 'Ошибка при выполнении задачи')
} finally {
setIsCompleting(false)
}
}
}
const handleCloseDetail = () => {
setSelectedTaskForDetail(null)
}
const handleAddClick = () => {
onNavigate?.('task-form', { taskId: undefined })
}
const toggleCompletedExpanded = (projectName) => {
setExpandedCompleted(prev => ({
...prev,
[projectName]: !prev[projectName]
}))
}
// Получаем все проекты из наград задачи и подзадач
const getTaskProjects = (task) => {
const projects = new Set()
const detail = taskDetails[task.id]
if (detail) {
// Проекты из основной задачи
if (detail.rewards) {
detail.rewards.forEach(reward => {
if (reward.project_name) {
projects.add(reward.project_name)
}
})
}
// Проекты из подзадач
if (detail.subtasks) {
detail.subtasks.forEach(subtask => {
if (subtask.rewards) {
subtask.rewards.forEach(reward => {
if (reward.project_name) {
projects.add(reward.project_name)
}
})
}
})
}
}
return Array.from(projects)
}
// Функция для проверки, является ли период нулевым
const isZeroPeriod = (intervalStr) => {
if (!intervalStr) return false
const parts = intervalStr.trim().split(/\s+/)
if (parts.length < 1) return false
const value = parseInt(parts[0], 10)
return !isNaN(value) && value === 0
}
// Функция для парсинга PostgreSQL INTERVAL и добавления к дате
const addIntervalToDate = (date, intervalStr) => {
if (!intervalStr) return null
const result = new Date(date)
// Парсим строку интервала (формат: "1 day", "2 hours", "3 months", etc.)
const parts = intervalStr.trim().split(/\s+/)
if (parts.length < 2) return null
const value = parseInt(parts[0], 10)
if (isNaN(value)) return null
const unit = parts[1].toLowerCase()
switch (unit) {
case 'minute':
case 'minutes':
result.setMinutes(result.getMinutes() + value)
break
case 'hour':
case 'hours':
result.setHours(result.getHours() + value)
break
case 'day':
case 'days':
result.setDate(result.getDate() + value)
break
case 'week':
case 'weeks':
result.setDate(result.getDate() + value * 7)
break
case 'month':
case 'months':
result.setMonth(result.getMonth() + value)
break
case 'year':
case 'years':
result.setFullYear(result.getFullYear() + value)
break
default:
return null
}
return result
}
// Группируем задачи по проектам
const groupedTasks = useMemo(() => {
const today = new Date()
today.setHours(0, 0, 0, 0)
const groups = {}
tasks.forEach(task => {
const projects = getTaskProjects(task)
// Если у задачи нет проектов, добавляем в группу "Без проекта"
if (projects.length === 0) {
projects.push('Без проекта')
}
// Определяем, в какую группу попадает задача
let isCompleted = false
// Если у задачи период повторения = 0, она всегда в невыполненных
if (task.repetition_period && isZeroPeriod(task.repetition_period)) {
isCompleted = false
} else if (task.repetition_period) {
// Если есть repetition_period (и он не 0), проверяем логику повторения
if (task.last_completed_at) {
const lastCompleted = new Date(task.last_completed_at)
const nextDueDate = addIntervalToDate(lastCompleted, task.repetition_period)
if (nextDueDate) {
// Округляем до начала дня
nextDueDate.setHours(0, 0, 0, 0)
// Если nextDueDate > today, то задача в выполненных
isCompleted = nextDueDate.getTime() > today.getTime()
} else {
// Если не удалось распарсить интервал, используем старую логику
const completedDate = new Date(task.last_completed_at)
completedDate.setHours(0, 0, 0, 0)
isCompleted = completedDate.getTime() === today.getTime()
}
} else {
// Если нет last_completed_at, то в обычной группе
isCompleted = false
}
} else {
// Если repetition_period == null, используем старую логику
if (task.last_completed_at) {
const completedDate = new Date(task.last_completed_at)
completedDate.setHours(0, 0, 0, 0)
isCompleted = completedDate.getTime() === today.getTime()
} else {
isCompleted = false
}
}
projects.forEach(projectName => {
if (!groups[projectName]) {
groups[projectName] = {
notCompleted: [],
completed: []
}
}
if (isCompleted) {
groups[projectName].completed.push(task)
} else {
groups[projectName].notCompleted.push(task)
}
})
})
return groups
}, [tasks, taskDetails])
const renderTaskItem = (task) => {
const detail = taskDetails[task.id]
const hasProgression = detail?.task?.progression_base != null
const hasSubtasks = detail?.subtasks && detail.subtasks.length > 0
const showDetailOnCheckmark = hasProgression || hasSubtasks
return (
<div
key={task.id}
className="task-item"
onClick={() => handleTaskClick(task)}
>
<div className="task-item-content">
<div
className={`task-checkmark ${showDetailOnCheckmark ? 'task-checkmark-detail' : ''}`}
onClick={(e) => handleCheckmarkClick(task, e)}
title={showDetailOnCheckmark ? 'Открыть детали' : 'Выполнить задачу'}
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="10" cy="10" r="9" stroke="currentColor" strokeWidth="2" fill="none" className="checkmark-circle" />
<path d="M6 10 L9 13 L14 7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="checkmark-check" />
</svg>
</div>
<div className="task-name">{task.name}</div>
<div className="task-actions">
<span className="task-completed-count">{task.completed}</span>
</div>
</div>
</div>
)
}
// Показываем загрузку только если данных нет и это не фоновая загрузка
// Проверяем наличие данных более надежно: либо в data, либо в tasks
// Важно: проверяем оба источника данных, так как они могут обновляться асинхронно
const hasDataInProps = data && Array.isArray(data) && data.length > 0
const hasDataInState = tasks && Array.isArray(tasks) && tasks.length > 0
const hasData = hasDataInProps || hasDataInState
// Показываем загрузку только если:
// 1. Идет загрузка (loading = true)
// 2. Это не фоновая загрузка (backgroundLoading = false)
// 3. Данных нет (hasData = false)
// Это предотвращает показ загрузки при переключении табов, когда данные уже есть
if (loading && !backgroundLoading && !hasData) {
return (
<div className="task-list">
<div className="loading">Загрузка...</div>
</div>
)
}
const projectNames = Object.keys(groupedTasks).sort()
return (
<div className="task-list">
{toast && (
<Toast
message={toast.message}
onClose={() => setToast(null)}
/>
)}
<button onClick={handleAddClick} className="add-task-button">
Добавить
</button>
{loadingDetails && tasks.length > 0 && (
<div className="loading-details">Загрузка деталей задач...</div>
)}
{projectNames.length === 0 && !loading && tasks.length === 0 && (
<div className="empty-state">
<p>Задач пока нет. Добавьте задачу через кнопку "Добавить".</p>
</div>
)}
{projectNames.map(projectName => {
const group = groupedTasks[projectName]
const hasCompleted = group.completed.length > 0
const isExpanded = expandedCompleted[projectName]
return (
<div key={projectName} className="project-group">
<div className="project-group-header">
<h3 className="project-group-title">{projectName}</h3>
</div>
{group.notCompleted.length > 0 && (
<div className="task-group">
{group.notCompleted.map(renderTaskItem)}
</div>
)}
{hasCompleted && (
<div className="completed-section">
<button
className="completed-toggle"
onClick={() => toggleCompletedExpanded(projectName)}
>
<span className="completed-toggle-icon">
{isExpanded ? '▼' : '▶'}
</span>
<span>Выполненные ({group.completed.length})</span>
</button>
{isExpanded && (
<div className="task-group completed-tasks">
{group.completed.map(renderTaskItem)}
</div>
)}
</div>
)}
{group.notCompleted.length === 0 && !hasCompleted && (
<div className="empty-group">Нет задач в этой группе</div>
)}
</div>
)
})}
{/* Модальное окно для деталей задачи */}
{selectedTaskForDetail && (
<TaskDetail
taskId={selectedTaskForDetail}
onClose={handleCloseDetail}
onRefresh={onRefresh}
onTaskCompleted={() => setToast({ message: 'Задача выполнена' })}
/>
)}
</div>
)
}
export default TaskList

View File

@@ -0,0 +1,34 @@
.toast {
position: fixed;
bottom: calc(80px + env(safe-area-inset-bottom, 0px));
left: 50%;
transform: translateX(-50%) translateY(100px);
z-index: 1000;
background: white;
border-radius: 0.5rem;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15);
padding: 1rem 1.5rem;
min-width: 250px;
max-width: 400px;
opacity: 0;
transition: all 0.3s ease-out;
}
.toast-visible {
transform: translateX(-50%) translateY(0);
opacity: 1;
}
.toast-content {
display: flex;
align-items: center;
gap: 0.75rem;
}
.toast-message {
color: #1f2937;
font-size: 0.875rem;
font-weight: 500;
line-height: 1.5;
}

View File

@@ -0,0 +1,30 @@
import React, { useEffect, useState } from 'react'
import './Toast.css'
function Toast({ message, onClose, duration = 3000 }) {
const [isVisible, setIsVisible] = useState(true)
useEffect(() => {
const timer = setTimeout(() => {
setIsVisible(false)
setTimeout(() => {
onClose?.()
}, 300) // Ждем завершения анимации
}, duration)
return () => clearTimeout(timer)
}, [duration, onClose])
if (!isVisible) return null
return (
<div className={`toast ${isVisible ? 'toast-visible' : ''}`}>
<div className="toast-content">
<span className="toast-message">{message}</span>
</div>
</div>
)
}
export default Toast