From 79430ba7f02e39713f825d235ad0a14200b64f33 Mon Sep 17 00:00:00 2001 From: poignatov Date: Sun, 4 Jan 2026 19:37:59 +0300 Subject: [PATCH] =?UTF-8?q?v2.9.0:=20=D0=A3=D0=BB=D1=83=D1=87=D1=88=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=20=D1=8D=D0=BA=D1=80=D0=B0=D0=BD=D0=B0=20?= =?UTF-8?q?=D1=81=D0=BF=D0=B8=D1=81=D0=BA=D0=B0=20=D0=B7=D0=B0=D0=B4=D0=B0?= =?UTF-8?q?=D1=87=20-=20=D0=BE=D0=BF=D1=82=D0=B8=D0=BC=D0=B8=D0=B7=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D1=8F=20=D0=B7=D0=B0=D0=B3=D1=80=D1=83=D0=B7=D0=BA?= =?UTF-8?q?=D0=B8,=20toast=20=D1=83=D0=B2=D0=B5=D0=B4=D0=BE=D0=BC=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F,=20=D0=B8=D1=81=D0=BF=D1=80=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D1=86=D0=B5=D0=BD=D1=82?= =?UTF-8?q?=D1=80=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- VERSION | 2 +- play-life-web/package.json | 2 +- play-life-web/src/App.jsx | 115 ++++- play-life-web/src/components/TaskDetail.css | 193 +++++++++ play-life-web/src/components/TaskDetail.jsx | 206 +++++++++ play-life-web/src/components/TaskList.jsx | 448 ++++++++++++++++++++ play-life-web/src/components/Toast.css | 34 ++ play-life-web/src/components/Toast.jsx | 30 ++ 8 files changed, 1023 insertions(+), 7 deletions(-) create mode 100644 play-life-web/src/components/TaskDetail.css create mode 100644 play-life-web/src/components/TaskDetail.jsx create mode 100644 play-life-web/src/components/TaskList.jsx create mode 100644 play-life-web/src/components/Toast.css create mode 100644 play-life-web/src/components/Toast.jsx diff --git a/VERSION b/VERSION index e43686a..c8e38b6 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.8.6 +2.9.0 diff --git a/play-life-web/package.json b/play-life-web/package.json index f99181f..91f8794 100644 --- a/play-life-web/package.json +++ b/play-life-web/package.json @@ -1,6 +1,6 @@ { "name": "play-life-web", - "version": "2.6.1", + "version": "2.9.0", "type": "module", "scripts": { "dev": "vite", diff --git a/play-life-web/src/App.jsx b/play-life-web/src/App.jsx index 0037d63..92abfd8 100644 --- a/play-life-web/src/App.jsx +++ b/play-life-web/src/App.jsx @@ -8,6 +8,8 @@ import TestConfigSelection from './components/TestConfigSelection' import AddConfig from './components/AddConfig' import TestWords from './components/TestWords' import Profile from './components/Profile' +import TaskList from './components/TaskList' +import TaskForm from './components/TaskForm' import { AuthProvider, useAuth } from './components/auth/AuthContext' import AuthScreen from './components/auth/AuthScreen' @@ -42,6 +44,8 @@ function AppContent() { 'test-config': false, 'add-config': false, test: false, + tasks: false, + 'task-form': false, profile: false, }) @@ -55,6 +59,8 @@ function AppContent() { 'test-config': false, 'add-config': false, test: false, + tasks: false, + 'task-form': false, profile: false, }) @@ -64,16 +70,19 @@ function AppContent() { // Кеширование данных const [currentWeekData, setCurrentWeekData] = useState(null) const [fullStatisticsData, setFullStatisticsData] = useState(null) + const [tasksData, setTasksData] = useState(null) // Состояния загрузки для каждого таба (показываются только при первой загрузке) const [currentWeekLoading, setCurrentWeekLoading] = useState(false) const [fullStatisticsLoading, setFullStatisticsLoading] = useState(false) const [prioritiesLoading, setPrioritiesLoading] = useState(false) + const [tasksLoading, setTasksLoading] = useState(false) // Состояния фоновой загрузки (не показываются визуально) const [currentWeekBackgroundLoading, setCurrentWeekBackgroundLoading] = useState(false) const [fullStatisticsBackgroundLoading, setFullStatisticsBackgroundLoading] = useState(false) const [prioritiesBackgroundLoading, setPrioritiesBackgroundLoading] = useState(false) + const [tasksBackgroundLoading, setTasksBackgroundLoading] = useState(false) // Ошибки const [currentWeekError, setCurrentWeekError] = useState(null) @@ -94,7 +103,7 @@ function AppContent() { try { const savedTab = window.localStorage?.getItem('activeTab') - const validTabs = ['current', 'priorities', 'full', 'words', 'add-words', 'test-config', 'add-config', 'test', 'profile'] + const validTabs = ['current', 'priorities', 'full', 'words', 'add-words', 'test-config', 'add-config', 'test', 'tasks', 'task-form', 'profile'] if (savedTab && validTabs.includes(savedTab)) { setActiveTab(savedTab) setLoadedTabs(prev => ({ ...prev, [savedTab]: true })) @@ -194,6 +203,30 @@ function AppContent() { } }, [authFetch]) + const fetchTasksData = useCallback(async (isBackground = false) => { + try { + if (isBackground) { + setTasksBackgroundLoading(true) + } else { + setTasksLoading(true) + } + const response = await authFetch('/api/tasks') + if (!response.ok) { + throw new Error('Ошибка загрузки данных') + } + const jsonData = await response.json() + setTasksData(jsonData) + } catch (err) { + console.error('Ошибка загрузки списка задач:', err) + } finally { + if (isBackground) { + setTasksBackgroundLoading(false) + } else { + setTasksLoading(false) + } + } + }, [authFetch]) + // Используем ref для отслеживания инициализации табов (чтобы избежать лишних пересозданий функции) const tabsInitializedRef = useRef({ current: false, @@ -204,6 +237,8 @@ function AppContent() { 'test-config': false, 'add-config': false, test: false, + tasks: false, + 'task-form': false, profile: false, }) @@ -211,6 +246,7 @@ function AppContent() { const cacheRef = useRef({ current: null, full: null, + tasks: null, }) // Обновляем ref при изменении данных @@ -222,6 +258,10 @@ function AppContent() { cacheRef.current.full = fullStatisticsData }, [fullStatisticsData]) + useEffect(() => { + cacheRef.current.tasks = tasksData + }, [tasksData]) + // Функция для загрузки данных таба const loadTabData = useCallback((tab, isBackground = false) => { if (tab === 'current') { @@ -275,8 +315,21 @@ function AppContent() { // Возврат на таб - фоновая загрузка setTestConfigRefreshTrigger(prev => prev + 1) } + } else if (tab === 'tasks') { + const hasCache = cacheRef.current.tasks !== null + const isInitialized = tabsInitializedRef.current.tasks + + if (!isInitialized) { + // Первая загрузка таба - загружаем с индикатором + fetchTasksData(false) + tabsInitializedRef.current.tasks = true + setTabsInitialized(prev => ({ ...prev, tasks: true })) + } else if (hasCache && isBackground) { + // Возврат на таб с кешем - фоновая загрузка + fetchTasksData(true) + } } - }, [fetchCurrentWeekData, fetchFullStatisticsData]) + }, [fetchCurrentWeekData, fetchFullStatisticsData, fetchTasksData]) // Функция для обновления всех данных (для кнопки Refresh, если она есть) const refreshAllData = useCallback(async () => { @@ -325,13 +378,19 @@ function AppContent() { if (tab === 'full' && activeTab === 'full') { // При повторном клике на "Полная статистика" сбрасываем выбранный проект setSelectedProject(null) - } else if (tab !== activeTab) { + } else if (tab !== activeTab || tab === 'task-form') { + // Для task-form всегда обновляем параметры, даже если это тот же таб markTabAsLoaded(tab) // Сбрасываем tabParams при переходе с add-config на другой таб if (activeTab === 'add-config' && tab !== 'add-config') { setTabParams({}) } else { - setTabParams(params) + // Для task-form явно удаляем taskId, если он undefined + if (tab === 'task-form' && params.taskId === undefined) { + setTabParams({}) + } else { + setTabParams(params) + } } setActiveTab(tab) if (tab === 'current') { @@ -341,6 +400,11 @@ function AppContent() { if (activeTab === 'add-words' && tab === 'words') { setWordsRefreshTrigger(prev => prev + 1) } + // Обновляем список задач при возврате из экрана редактирования + // Используем фоновую загрузку, чтобы не показывать индикатор загрузки + if (activeTab === 'task-form' && tab === 'tasks') { + fetchTasksData(true) + } // Загрузка данных произойдет в useEffect при изменении activeTab } } @@ -393,7 +457,7 @@ function AppContent() { }, [activeTab]) // Определяем, нужно ли скрывать нижнюю панель (для fullscreen экранов) - const isFullscreenTab = activeTab === 'test' || activeTab === 'add-words' || activeTab === 'add-config' + const isFullscreenTab = activeTab === 'test' || activeTab === 'add-words' || activeTab === 'add-config' || activeTab === 'task-form' return (
@@ -493,6 +557,28 @@ function AppContent() {
)} + {loadedTabs.tasks && ( +
+ fetchTasksData(isBackground)} + /> +
+ )} + + {loadedTabs['task-form'] && ( +
+ +
+ )} + {loadedTabs.profile && (
@@ -546,6 +632,25 @@ function AppContent() {
)} + +
+ +
+ {loading && ( +
Загрузка...
+ )} + + {error && ( +
{error}
+ )} + + {!loading && !error && taskDetail && ( + <> + {subtasks && subtasks.length > 0 && ( +
+ {subtasks.map((subtask) => { + const subtaskName = subtask.task.name || 'Подзадача' + return ( +
+ +
+ ) + })} +
+ )} + +
+ {hasProgression ? ( +
+ setProgressionValue(e.target.value)} + placeholder={`Значение (~${task.progression_base})`} + className="progression-input" + /> + {progressionValue.trim() && ( + + )} +
+ ) : ( + + )} +
+ + )} +
+ + + ) +} + +export default TaskDetail + diff --git a/play-life-web/src/components/TaskList.jsx b/play-life-web/src/components/TaskList.jsx new file mode 100644 index 0000000..4d70f1b --- /dev/null +++ b/play-life-web/src/components/TaskList.jsx @@ -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 ( +
handleTaskClick(task)} + > +
+
handleCheckmarkClick(task, e)} + title={showDetailOnCheckmark ? 'Открыть детали' : 'Выполнить задачу'} + > + + + + +
+
{task.name}
+
+ {task.completed} +
+
+
+ ) + } + + // Показываем загрузку только если данных нет и это не фоновая загрузка + // Проверяем наличие данных более надежно: либо в 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 ( +
+
Загрузка...
+
+ ) + } + + const projectNames = Object.keys(groupedTasks).sort() + + return ( +
+ {toast && ( + setToast(null)} + /> + )} + + + {loadingDetails && tasks.length > 0 && ( +
Загрузка деталей задач...
+ )} + + {projectNames.length === 0 && !loading && tasks.length === 0 && ( +
+

Задач пока нет. Добавьте задачу через кнопку "Добавить".

+
+ )} + + {projectNames.map(projectName => { + const group = groupedTasks[projectName] + const hasCompleted = group.completed.length > 0 + const isExpanded = expandedCompleted[projectName] + + return ( +
+
+

{projectName}

+
+ + {group.notCompleted.length > 0 && ( +
+ {group.notCompleted.map(renderTaskItem)} +
+ )} + + {hasCompleted && ( +
+ + {isExpanded && ( +
+ {group.completed.map(renderTaskItem)} +
+ )} +
+ )} + + {group.notCompleted.length === 0 && !hasCompleted && ( +
Нет задач в этой группе
+ )} +
+ ) + })} + + {/* Модальное окно для деталей задачи */} + {selectedTaskForDetail && ( + setToast({ message: 'Задача выполнена' })} + /> + )} +
+ ) +} + +export default TaskList + diff --git a/play-life-web/src/components/Toast.css b/play-life-web/src/components/Toast.css new file mode 100644 index 0000000..6c2574e --- /dev/null +++ b/play-life-web/src/components/Toast.css @@ -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; +} + diff --git a/play-life-web/src/components/Toast.jsx b/play-life-web/src/components/Toast.jsx new file mode 100644 index 0000000..ddc9976 --- /dev/null +++ b/play-life-web/src/components/Toast.jsx @@ -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 ( +
+
+ {message} +
+
+ ) +} + +export default Toast +