import React, { useState, useEffect, useCallback, useRef } from 'react' import CurrentWeek from './components/CurrentWeek' import FullStatistics from './components/FullStatistics' import ProjectPriorityManager from './components/ProjectPriorityManager' import WordList from './components/WordList' import AddWords from './components/AddWords' import DictionaryList from './components/DictionaryList' import TestWords from './components/TestWords' import Profile from './components/Profile' import TaskList from './components/TaskList' import TaskForm from './components/TaskForm.jsx' import Wishlist from './components/Wishlist' import WishlistForm from './components/WishlistForm' import WishlistDetail from './components/WishlistDetail' import BoardForm from './components/BoardForm' import BoardJoinPreview from './components/BoardJoinPreview' import ShoppingList from './components/ShoppingList' import ShoppingItemForm from './components/ShoppingItemForm' import ShoppingBoardForm from './components/ShoppingBoardForm' import ShoppingBoardJoinPreview from './components/ShoppingBoardJoinPreview' import TodoistIntegration from './components/TodoistIntegration' import TelegramIntegration from './components/TelegramIntegration' import FitbitIntegration from './components/FitbitIntegration' import Tracking from './components/Tracking' import TrackingAccess from './components/TrackingAccess' import TrackingInviteAccept from './components/TrackingInviteAccept' import { AuthProvider, useAuth } from './components/auth/AuthContext' import AuthScreen from './components/auth/AuthScreen' import PWAUpdatePrompt from './components/PWAUpdatePrompt' // API endpoints (используем относительные пути, проксирование настроено в nginx/vite) const CURRENT_WEEK_API_URL = '/playlife-feed' const FULL_STATISTICS_API_URL = '/d2dc349a-0d13-49b2-a8f0-1ab094bfba9b' // Определяем основные табы (без крестика) и глубокие табы (с крестиком) const mainTabs = ['current', 'tasks', 'wishlist', 'shopping', 'profile'] const deepTabs = ['add-words', 'test', 'task-form', 'wishlist-form', 'wishlist-detail', 'board-form', 'board-join', 'words', 'dictionaries', 'todoist-integration', 'telegram-integration', 'fitbit-integration', 'full', 'priorities', 'tracking', 'tracking-access', 'tracking-invite', 'shopping-item-form', 'shopping-board-form', 'shopping-board-join'] /** * Гарантирует базовую запись истории для главного экрана перед глубоким табом. * После долгого бездействия PWA может перезапуститься с одной записью в истории; * кнопка "назад" тогда закрывает приложение. Эта функция добавляет запись для * экрана 'current', чтобы "назад" возвращала на главный экран. */ function ensureBaseHistory(deepTab, params = {}, url) { if (typeof window === 'undefined' || !deepTabs.includes(deepTab)) return if (window.history.length <= 1) { window.history.replaceState({ tab: 'current' }, '', '/') window.history.pushState({ tab: deepTab, params, previousTab: 'current' }, '', url) } else { window.history.replaceState({ tab: deepTab, params, previousTab: 'current' }, '', url) } } function AppContent() { const { authFetch, isAuthenticated, loading: authLoading } = useAuth() const prevIsAuthenticatedRef = useRef(null) // Все хуки должны быть объявлены до условных возвратов const [activeTab, setActiveTab] = useState('current') const [selectedProject, setSelectedProject] = useState(null) const [loadedTabs, setLoadedTabs] = useState({ current: false, priorities: false, full: false, words: false, 'add-words': false, dictionaries: false, test: false, tasks: false, 'task-form': false, wishlist: false, 'wishlist-form': false, 'wishlist-detail': false, 'board-form': false, 'board-join': false, profile: false, 'todoist-integration': false, 'telegram-integration': false, 'fitbit-integration': false, tracking: false, 'tracking-access': false, 'tracking-invite': false, shopping: false, 'shopping-item-form': false, 'shopping-board-form': false, 'shopping-board-join': false, }) // Отслеживаем, какие табы уже были загружены (для предотвращения повторных загрузок) const [tabsInitialized, setTabsInitialized] = useState({ current: false, priorities: false, full: false, words: false, 'add-words': false, dictionaries: false, test: false, tasks: false, 'task-form': false, wishlist: false, 'wishlist-form': false, 'wishlist-detail': false, 'board-form': false, 'board-join': false, profile: false, 'todoist-integration': false, 'telegram-integration': false, 'fitbit-integration': false, tracking: false, 'tracking-access': false, 'tracking-invite': false, shopping: false, 'shopping-item-form': false, 'shopping-board-form': false, 'shopping-board-join': false, }) // Параметры для навигации между вкладками const [tabParams, setTabParams] = useState({}) // Предыдущий таб для возврата из модальных окон const [previousTab, setPreviousTab] = useState(null) // Счётчик для сброса формы товара при каждом открытии const [shoppingItemFormKey, setShoppingItemFormKey] = useState(0) // Модальное окно выбора типа задачи const [showAddModal, setShowAddModal] = useState(false) // Ref для функции открытия модала добавления записи в CurrentWeek const currentWeekAddModalRef = useRef(null) // Кеширование данных const [currentWeekData, setCurrentWeekData] = useState(null) const [fullStatisticsData, setFullStatisticsData] = useState(null) const [tasksData, setTasksData] = useState(null) const [todayEntriesData, setTodayEntriesData] = useState(null) // Состояния загрузки для каждого таба (показываются только при первой загрузке) const [currentWeekLoading, setCurrentWeekLoading] = useState(false) const [fullStatisticsLoading, setFullStatisticsLoading] = useState(false) const [prioritiesLoading, setPrioritiesLoading] = useState(false) const [tasksLoading, setTasksLoading] = useState(false) const [todayEntriesLoading, setTodayEntriesLoading] = useState(false) // Состояния фоновой загрузки (не показываются визуально) const [currentWeekBackgroundLoading, setCurrentWeekBackgroundLoading] = useState(false) const [fullStatisticsBackgroundLoading, setFullStatisticsBackgroundLoading] = useState(false) const [prioritiesBackgroundLoading, setPrioritiesBackgroundLoading] = useState(false) const [tasksBackgroundLoading, setTasksBackgroundLoading] = useState(false) const [todayEntriesBackgroundLoading, setTodayEntriesBackgroundLoading] = useState(false) // Ошибки const [currentWeekError, setCurrentWeekError] = useState(null) const [fullStatisticsError, setFullStatisticsError] = useState(null) const [prioritiesError, setPrioritiesError] = useState(null) const [tasksError, setTasksError] = useState(null) const [todayEntriesError, setTodayEntriesError] = useState(null) // Состояние для кнопки Refresh (если она есть) const [isRefreshing, setIsRefreshing] = useState(false) const [prioritiesRefreshTrigger, setPrioritiesRefreshTrigger] = useState(0) const [dictionariesRefreshTrigger, setDictionariesRefreshTrigger] = useState(0) const [wordsRefreshTrigger, setWordsRefreshTrigger] = useState(0) const [wishlistRefreshTrigger, setWishlistRefreshTrigger] = useState(0) const [shoppingRefreshTrigger, setShoppingRefreshTrigger] = useState(0) // Восстанавливаем последний выбранный таб после перезагрузки const [isInitialized, setIsInitialized] = useState(false) // Переключение на экран прогрессии после успешной авторизации useEffect(() => { // Обновляем ref только после того, как authLoading стал false if (!authLoading) { const wasNotAuthenticated = prevIsAuthenticatedRef.current === false // Обновляем ref только если инициализация завершена, // чтобы не потерять переход false→true при ожидании isInitialized if (isInitialized) { prevIsAuthenticatedRef.current = isAuthenticated } // Проверяем, что это новая авторизация (переход с false на true) // и что инициализация уже завершена (чтобы не конфликтовать с восстановлением из URL/localStorage) if (wasNotAuthenticated && isAuthenticated && isInitialized) { // Сбрасываем ошибки, кеш данных и состояние инициализации табов при повторной авторизации setCurrentWeekError(null) setFullStatisticsError(null) setPrioritiesError(null) setTasksError(null) setTodayEntriesError(null) setCurrentWeekData(null) setFullStatisticsData(null) setTasksData(null) setTodayEntriesData(null) // Сбрасываем инициализацию табов, чтобы данные загрузились заново Object.keys(tabsInitializedRef.current).forEach(key => { tabsInitializedRef.current[key] = false }) cacheRef.current = { current: null, full: null, tasks: null, todayEntries: null } lastLoadedTabRef.current = null // Переключаемся на экран прогресса только если нет таба в URL const urlParams = new URLSearchParams(window.location.search) const tabFromUrl = urlParams.get('tab') // Если в URL нет таба, переключаемся на current (экран прогресса) if (!tabFromUrl) { setActiveTab('current') setLoadedTabs(prev => ({ ...prev, current: true })) // Очищаем URL, так как current - это основной таб const url = new URL(window.location) url.searchParams.delete('tab') url.searchParams.forEach((value, key) => { url.searchParams.delete(key) }) window.history.replaceState({ tab: 'current' }, '', url) } } } }, [isAuthenticated, isInitialized, authLoading]) // Инициализация из URL (только для глубоких табов) или localStorage useEffect(() => { if (isInitialized) return try { // Проверяем путь /invite/:token для присоединения к доске const path = window.location.pathname if (path.startsWith('/invite/')) { const token = path.replace('/invite/', '') if (token) { const url = '/?tab=board-join&inviteToken=' + token ensureBaseHistory('board-join', { inviteToken: token }, url) setActiveTab('board-join') setLoadedTabs(prev => ({ ...prev, 'board-join': true })) setTabParams({ inviteToken: token }) setIsInitialized(true) return } } // Проверяем путь /shopping-invite/:token для присоединения к shopping доске if (path.startsWith('/shopping-invite/')) { const token = path.replace('/shopping-invite/', '') if (token) { const url = '/?tab=shopping-board-join&inviteToken=' + token ensureBaseHistory('shopping-board-join', { inviteToken: token }, url) setActiveTab('shopping-board-join') setLoadedTabs(prev => ({ ...prev, 'shopping-board-join': true })) setTabParams({ inviteToken: token }) setIsInitialized(true) return } } // Проверяем путь /tracking/invite/:token if (path.startsWith('/tracking/invite/')) { const token = path.replace('/tracking/invite/', '') if (token) { const url = '/?tab=tracking-invite&inviteToken=' + token ensureBaseHistory('tracking-invite', { inviteToken: token }, url) setActiveTab('tracking-invite') setLoadedTabs(prev => ({ ...prev, 'tracking-invite': true })) setTabParams({ inviteToken: token }) setIsInitialized(true) return } } // Проверяем параметры OAuth callback от Fitbit const urlParams = new URLSearchParams(window.location.search) const integration = urlParams.get('integration') if (integration === 'fitbit') { const status = urlParams.get('status') const message = urlParams.get('message') let newUrl = '/?tab=fitbit-integration&integration=fitbit' if (status) newUrl += `&status=${status}` if (message) newUrl += `&message=${message}` const fitbitParams = { integration: 'fitbit' } if (status) fitbitParams.status = status if (message) fitbitParams.message = message ensureBaseHistory('fitbit-integration', fitbitParams, newUrl) setActiveTab('fitbit-integration') setLoadedTabs(prev => ({ ...prev, 'fitbit-integration': true })) setIsInitialized(true) return } // Проверяем URL только для глубоких табов const tabFromUrl = urlParams.get('tab') const validTabs = ['current', 'priorities', 'full', 'words', 'add-words', 'dictionaries', 'test', 'tasks', 'task-form', 'wishlist', 'wishlist-form', 'wishlist-detail', 'board-form', 'board-join', 'profile', 'todoist-integration', 'telegram-integration', 'fitbit-integration', 'tracking', 'tracking-access', 'tracking-invite', 'shopping', 'shopping-item-form', 'shopping-board-form', 'shopping-board-join'] if (tabFromUrl && validTabs.includes(tabFromUrl) && deepTabs.includes(tabFromUrl) && window.history.length > 1) { // Восстанавливаем глубокий таб из URL только если есть история (не рестарт PWA) const params = {} urlParams.forEach((value, key) => { if (key !== 'tab') { try { params[key] = JSON.parse(value) } catch { params[key] = value } } }) const deepTabUrl = window.location.pathname + window.location.search ensureBaseHistory(tabFromUrl, params, deepTabUrl) setActiveTab(tabFromUrl) setLoadedTabs(prev => ({ ...prev, [tabFromUrl]: true })) if (Object.keys(params).length > 0) { setTabParams(params) if (tabFromUrl === 'full' && params.selectedProject) { setSelectedProject(params.selectedProject) } } } else { // При рестарте PWA (history.length <= 1) с deep tab в URL — сбрасываем на current if (tabFromUrl && deepTabs.includes(tabFromUrl) && window.history.length <= 1) { window.history.replaceState({ tab: 'current' }, '', '/') setActiveTab('current') setLoadedTabs(prev => ({ ...prev, 'current': true })) } else { // Проверяем localStorage для основного таба const savedTab = window.localStorage?.getItem('activeTab') if (savedTab && validTabs.includes(savedTab) && mainTabs.includes(savedTab)) { setActiveTab(savedTab) setLoadedTabs(prev => ({ ...prev, [savedTab]: true })) } else { // Если нет сохранённого таба — активируем current по умолчанию setLoadedTabs(prev => ({ ...prev, current: true })) } // Очищаем URL от параметров таба, если это основной таб if (tabFromUrl && mainTabs.includes(tabFromUrl)) { const url = new URL(window.location) url.searchParams.delete('tab') url.searchParams.forEach((value, key) => { url.searchParams.delete(key) }) window.history.replaceState({}, '', url) } } } setIsInitialized(true) } catch (err) { console.warn('Не удалось прочитать активный таб', err) setIsInitialized(true) } }, [isInitialized]) const markTabAsLoaded = useCallback((tab) => { setLoadedTabs(prev => (prev[tab] ? prev : { ...prev, [tab]: true })) }, []) // Функция для обновления URL (только для глубоких табов) const updateUrl = useCallback((tab, params = {}, previousTab = null, replace = false) => { if (!deepTabs.includes(tab)) { // Для основных табов не обновляем URL return } const url = new URL(window.location) url.searchParams.set('tab', tab) // Удаляем старые параметры таба const keysToRemove = [] url.searchParams.forEach((value, key) => { if (key !== 'tab') { keysToRemove.push(key) } }) keysToRemove.forEach(key => url.searchParams.delete(key)) // Добавляем новые параметры Object.entries(params).forEach(([key, value]) => { if (value !== undefined && value !== null) { url.searchParams.set(key, typeof value === 'object' ? JSON.stringify(value) : value) } }) // Если стек пуст (после перезапуска PWA), добавляем базовую запись 'current' if (window.history.length <= 1) { window.history.replaceState({ tab: 'current' }, '', '/') } // Сохраняем предыдущий таб в state для восстановления при "Назад" if (replace) { window.history.replaceState({ tab, params, previousTab }, '', url) } else { window.history.pushState({ tab, params, previousTab }, '', url) } }, []) // deepTabs - константа, не нужно в зависимостях // Функция для очистки URL (при возврате к основному табу) const clearUrl = useCallback((tab = null, usePushState = false) => { const url = new URL(window.location) const hasTabParam = url.searchParams.has('tab') if (hasTabParam) { url.searchParams.delete('tab') url.searchParams.forEach((value, key) => { url.searchParams.delete(key) }) // Сохраняем текущий таб в state для восстановления при "Назад" if (usePushState && tab) { window.history.pushState({ tab }, '', url) } else { window.history.replaceState(tab ? { tab } : {}, '', url) } } else if (tab) { // Если URL уже чистый, но нужно сохранить state таба if (usePushState) { window.history.pushState({ tab }, '', url) } else { window.history.replaceState({ tab }, '', url) } } }, []) // Функция для обновления URL без создания новой записи в истории (для обновления параметров того же таба) const replaceUrl = useCallback((tab, params = {}) => { if (!deepTabs.includes(tab)) { return } const url = new URL(window.location) url.searchParams.set('tab', tab) // Удаляем старые параметры таба const keysToRemove = [] url.searchParams.forEach((value, key) => { if (key !== 'tab') { keysToRemove.push(key) } }) keysToRemove.forEach(key => url.searchParams.delete(key)) // Добавляем новые параметры Object.entries(params).forEach(([key, value]) => { if (value !== undefined && value !== null) { url.searchParams.set(key, typeof value === 'object' ? JSON.stringify(value) : value) } }) // Сохраняем текущий state, чтобы не потерять previousTab const currentState = window.history.state || {} window.history.replaceState({ ...currentState, tab, params }, '', url) }, []) const fetchCurrentWeekData = useCallback(async (isBackground = false) => { try { if (isBackground) { setCurrentWeekBackgroundLoading(true) } else { setCurrentWeekLoading(true) } setCurrentWeekError(null) const response = await authFetch(CURRENT_WEEK_API_URL) if (!response.ok) { throw new Error('Ошибка загрузки данных') } const jsonData = await response.json() // Обрабатываем ответ: приходит массив с одним объектом [{total: ..., projects: [...]}] let projects = [] let total = null let groupProgress1 = null let groupProgress2 = null let groupProgress0 = null if (Array.isArray(jsonData) && jsonData.length > 0) { // Если ответ - массив, проверяем первый элемент const firstItem = jsonData[0] if (firstItem && typeof firstItem === 'object') { // Если первый элемент - объект с полями total и projects if (firstItem.projects && Array.isArray(firstItem.projects)) { projects = firstItem.projects total = firstItem.total !== undefined ? firstItem.total : null groupProgress1 = firstItem.group_progress_1 !== undefined ? firstItem.group_progress_1 : null groupProgress2 = firstItem.group_progress_2 !== undefined ? firstItem.group_progress_2 : null groupProgress0 = firstItem.group_progress_0 !== undefined ? firstItem.group_progress_0 : null } else { // Если это просто массив проектов projects = jsonData } } else { // Если это массив проектов напрямую projects = jsonData } } else if (jsonData && typeof jsonData === 'object' && !Array.isArray(jsonData)) { // Если ответ - объект напрямую projects = jsonData.projects || jsonData.data || [] total = jsonData.total !== undefined ? jsonData.total : null groupProgress1 = jsonData.group_progress_1 !== undefined ? jsonData.group_progress_1 : null groupProgress2 = jsonData.group_progress_2 !== undefined ? jsonData.group_progress_2 : null groupProgress0 = jsonData.group_progress_0 !== undefined ? jsonData.group_progress_0 : null } // Получаем желания и pending-баллы по проектам из ответа const wishes = jsonData?.wishes || [] const pendingScoresByProject = jsonData?.pending_scores_by_project && typeof jsonData.pending_scores_by_project === 'object' ? jsonData.pending_scores_by_project : {} setCurrentWeekData({ projects: Array.isArray(projects) ? projects : [], total: total, group_progress_1: groupProgress1, group_progress_2: groupProgress2, group_progress_0: groupProgress0, wishes: wishes, pending_scores_by_project: pendingScoresByProject }) } catch (err) { setCurrentWeekError(err.message) console.error('Ошибка загрузки данных текущей недели:', err) } finally { if (isBackground) { setCurrentWeekBackgroundLoading(false) } else { setCurrentWeekLoading(false) } } }, [authFetch]) const fetchFullStatisticsData = useCallback(async (isBackground = false) => { try { if (isBackground) { setFullStatisticsBackgroundLoading(true) } else { setFullStatisticsLoading(true) } setFullStatisticsError(null) const response = await authFetch(FULL_STATISTICS_API_URL) if (!response.ok) { throw new Error('Ошибка загрузки данных') } const jsonData = await response.json() setFullStatisticsData(jsonData) } catch (err) { setFullStatisticsError(err.message) console.error('Ошибка загрузки данных полной статистики:', err) } finally { if (isBackground) { setFullStatisticsBackgroundLoading(false) } else { setFullStatisticsLoading(false) } } }, [authFetch]) const fetchTasksData = useCallback(async (isBackground = false) => { try { if (isBackground) { setTasksBackgroundLoading(true) } else { setTasksLoading(true) } setTasksError(null) const response = await authFetch('/api/tasks') if (!response.ok) { throw new Error('Ошибка загрузки данных') } const jsonData = await response.json() setTasksData(jsonData) } catch (err) { console.error('Ошибка загрузки списка задач:', err) setTasksError(err.message || 'Ошибка загрузки данных') } finally { if (isBackground) { setTasksBackgroundLoading(false) } else { setTasksLoading(false) } } }, [authFetch]) const fetchTodayEntries = useCallback(async (isBackground = false, projectName = null, date = null) => { try { if (isBackground) { setTodayEntriesBackgroundLoading(true) } else { setTodayEntriesLoading(true) } setTodayEntriesError(null) // Формируем URL с опциональными параметрами project и date let url = '/api/today-entries' const params = [] if (projectName) { params.push(`project=${encodeURIComponent(projectName)}`) } if (date) { params.push(`date=${encodeURIComponent(date)}`) } if (params.length > 0) { url += `?${params.join('&')}` } const response = await authFetch(url) if (!response.ok) { throw new Error('Ошибка загрузки данных') } const jsonData = await response.json() setTodayEntriesData(Array.isArray(jsonData) ? jsonData : []) } catch (err) { setTodayEntriesError(err.message || 'Ошибка загрузки данных') console.error('Ошибка загрузки today entries:', err) } finally { if (isBackground) { setTodayEntriesBackgroundLoading(false) } else { setTodayEntriesLoading(false) } } }, [authFetch]) // Используем ref для отслеживания инициализации табов (чтобы избежать лишних пересозданий функции) const tabsInitializedRef = useRef({ current: false, priorities: false, full: false, words: false, 'add-words': false, dictionaries: false, test: false, tasks: false, 'task-form': false, profile: false, 'todoist-integration': false, 'telegram-integration': false, 'fitbit-integration': false, tracking: false, 'tracking-access': false, 'tracking-invite': false, }) // Используем ref для отслеживания кеша (чтобы не зависеть от состояния в useCallback) const cacheRef = useRef({ current: null, full: null, tasks: null, todayEntries: null, }) // Refs для отслеживания активного таба const prevActiveTabRef = useRef(null) const lastLoadedTabRef = useRef(null) // Отслеживаем последний загруженный таб, чтобы избежать двойной загрузки // Обновляем ref при изменении данных useEffect(() => { cacheRef.current.current = currentWeekData }, [currentWeekData]) useEffect(() => { cacheRef.current.full = fullStatisticsData }, [fullStatisticsData]) useEffect(() => { cacheRef.current.tasks = tasksData }, [tasksData]) useEffect(() => { cacheRef.current.todayEntries = todayEntriesData }, [todayEntriesData]) // Функция для загрузки данных таба const loadTabData = useCallback((tab, isBackground = false, projectName = null) => { if (tab === 'current') { const hasCache = cacheRef.current.current !== null const isInitialized = tabsInitializedRef.current.current if (!isInitialized) { // Первая загрузка таба - загружаем с индикатором fetchCurrentWeekData(false) tabsInitializedRef.current.current = true setTabsInitialized(prev => ({ ...prev, current: true })) } else if (hasCache && isBackground) { // Возврат на таб с кешем - фоновая загрузка fetchCurrentWeekData(true) } // Если нет кеша и это не первая загрузка - ничего не делаем (данные уже загружаются) } else if (tab === 'full') { const hasCache = cacheRef.current.full !== null const hasCurrentWeekCache = cacheRef.current.current !== null const isInitialized = tabsInitializedRef.current.full if (!isInitialized) { // Первая загрузка таба if (hasCache) { // Если есть кеш - используем фоновую загрузку, показываем старые данные fetchFullStatisticsData(true) } else { // Если кеша нет - загружаем с индикатором fetchFullStatisticsData(false) } // Также запускаем фоновую загрузку currentWeekData, если его нет if (!hasCurrentWeekCache) { fetchCurrentWeekData(true) } // todayEntries будет загружен в FullStatistics компоненте при выборе дня tabsInitializedRef.current.full = true setTabsInitialized(prev => ({ ...prev, full: true })) } else if (hasCache && isBackground) { // Возврат на таб с кешем - фоновая загрузка fetchFullStatisticsData(true) // Также запускаем фоновую загрузку currentWeekData, если его нет if (!hasCurrentWeekCache) { fetchCurrentWeekData(true) } // todayEntries будет загружен в FullStatistics компоненте при выборе дня } } else if (tab === 'priorities') { const isInitialized = tabsInitializedRef.current.priorities if (!isInitialized) { // Первая загрузка таба setPrioritiesRefreshTrigger(prev => prev + 1) tabsInitializedRef.current.priorities = true setTabsInitialized(prev => ({ ...prev, priorities: true })) } else if (isBackground) { // Возврат на таб - фоновая загрузка setPrioritiesRefreshTrigger(prev => prev + 1) } } else if (tab === 'dictionaries') { const isInitialized = tabsInitializedRef.current['dictionaries'] if (!isInitialized) { // Первая загрузка таба setDictionariesRefreshTrigger(prev => prev + 1) tabsInitializedRef.current['dictionaries'] = true setTabsInitialized(prev => ({ ...prev, 'dictionaries': true })) } else if (isBackground) { // Возврат на таб - фоновая загрузка setDictionariesRefreshTrigger(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, fetchTasksData, fetchTodayEntries]) // Функция для обновления всех данных (для кнопки Refresh, если она есть) const refreshAllData = useCallback(async () => { setIsRefreshing(true) setPrioritiesError(null) setCurrentWeekError(null) setFullStatisticsError(null) // Триггерим обновление приоритетов setPrioritiesRefreshTrigger(prev => prev + 1) // Загружаем все данные параллельно (не фоново) await Promise.all([ fetchCurrentWeekData(false), fetchFullStatisticsData(false), ]) setIsRefreshing(false) }, [fetchCurrentWeekData, fetchFullStatisticsData]) // Обработчик кнопки "назад" в браузере (только для глубоких табов) useEffect(() => { const handlePopState = (event) => { // Проверяем, есть ли открытые модальные окна в DOM const taskDetailModal = document.querySelector('.task-detail-modal-overlay') const wishlistDetailModal = document.querySelector('.wishlist-detail-modal-overlay') const conditionFormOverlay = document.querySelector('.condition-form-overlay') // Если есть открытые модальные окна, не обрабатываем здесь - компоненты сами закроют их if (taskDetailModal || wishlistDetailModal || conditionFormOverlay) { return } // Если это модальное окно, не обрабатываем здесь - компоненты сами закроют его if (event.state && event.state.modalOpen) { // Если модальных окон нет в DOM, это устаревшая запись — пропускаем её if (!taskDetailModal && !wishlistDetailModal) { window.history.back() } return } const validTabs = ['current', 'priorities', 'full', 'words', 'add-words', 'dictionaries', 'test', 'tasks', 'task-form', 'wishlist', 'wishlist-form', 'wishlist-detail', 'profile', 'todoist-integration', 'telegram-integration', 'tracking', 'tracking-access', 'tracking-invite', 'shopping', 'shopping-item-form', 'shopping-board-form', 'shopping-board-join'] // Проверяем state текущей записи истории (куда мы вернулись) if (event.state && event.state.tab) { const { tab, params = {} } = event.state if (validTabs.includes(tab)) { setActiveTab(tab) setTabParams(params) markTabAsLoaded(tab) // Если это экран full, устанавливаем selectedProject только если он есть в params if (tab === 'full') { setSelectedProject(params.selectedProject || null) } return } } // Если state пустой или не содержит таб, пытаемся восстановить из URL const urlParams = new URLSearchParams(window.location.search) const tabFromUrl = urlParams.get('tab') if (tabFromUrl && validTabs.includes(tabFromUrl) && deepTabs.includes(tabFromUrl)) { // Если в URL есть глубокий таб, восстанавливаем его setActiveTab(tabFromUrl) markTabAsLoaded(tabFromUrl) const params = {} urlParams.forEach((value, key) => { if (key !== 'tab') { try { params[key] = JSON.parse(value) } catch { params[key] = value } } }) setTabParams(params) // Если это экран full, устанавливаем selectedProject только если он есть в params if (tabFromUrl === 'full') { setSelectedProject(params.selectedProject || null) } } else { // Если в URL нет глубокого таба, значит мы вернулись на основной таб // Проверяем state - если там есть tab, используем его if (event.state && event.state.tab && validTabs.includes(event.state.tab)) { setActiveTab(event.state.tab) setTabParams({}) markTabAsLoaded(event.state.tab) setSelectedProject(null) clearUrl(event.state.tab) } else { // Если state пустой, используем сохраненный таб из localStorage const savedTab = window.localStorage?.getItem('activeTab') const validMainTab = savedTab && validTabs.includes(savedTab) ? savedTab : 'current' setActiveTab(validMainTab) setTabParams({}) markTabAsLoaded(validMainTab) setSelectedProject(null) clearUrl(validMainTab) } } } window.addEventListener('popstate', handlePopState) return () => { window.removeEventListener('popstate', handlePopState) } }, [markTabAsLoaded, clearUrl]) // mainTabs и deepTabs - константы, не нужно в зависимостях // Обновляем данные при возвращении экрана в фокус (фоново) useEffect(() => { const handleFocus = () => { if (document.visibilityState === 'visible') { // Загружаем данные активного таба фоново const projectName = activeTab === 'full' ? selectedProject : null loadTabData(activeTab, true, projectName) } } window.addEventListener('focus', handleFocus) document.addEventListener('visibilitychange', handleFocus) return () => { window.removeEventListener('focus', handleFocus) document.removeEventListener('visibilitychange', handleFocus) } }, [activeTab, loadTabData]) const handleProjectClick = (projectName) => { setSelectedProject(projectName) markTabAsLoaded('full') setTabParams({ selectedProject: projectName }) updateUrl('full', { selectedProject: projectName }, activeTab) setActiveTab('full') } const handleTabChange = (tab, params = {}, options = {}) => { if (tab === 'full' && activeTab === 'full') { // При повторном клике на "Полная статистика" сбрасываем выбранный проект setSelectedProject(null) setTabParams({}) updateUrl('full', {}, activeTab) } else if (tab !== activeTab || tab === 'task-form' || tab === 'wishlist-form' || tab === 'shopping-item-form' || (tab === 'words' && Object.keys(params).length > 0)) { // Для task-form, wishlist-form и shopping-item-form всегда обновляем параметры, даже если это тот же таб markTabAsLoaded(tab) // Определяем, является ли текущий таб глубоким const isCurrentTabDeep = deepTabs.includes(activeTab) const isNewTabDeep = deepTabs.includes(tab) const isCurrentTabMain = mainTabs.includes(activeTab) const isNewTabMain = mainTabs.includes(tab) { // Для task-form и wishlist-form явно удаляем параметры, только если нет никаких параметров // task-form может иметь taskId (редактирование), wishlistId (создание из желания), returnTo (возврат после создания), или isTest (создание теста) const isTaskFormWithNoParams = tab === 'task-form' && params.taskId === undefined && params.wishlistId === undefined && params.returnTo === undefined && params.isTest === undefined // Проверяем, что boardId не null и не undefined (null означает "нет доски", но это валидное значение) const hasBoardId = params.boardId !== null && params.boardId !== undefined const isWishlistFormWithNoParams = tab === 'wishlist-form' && params.wishlistId === undefined && params.newTaskId === undefined && !hasBoardId if (isTaskFormWithNoParams || isWishlistFormWithNoParams) { setTabParams({}) if (isNewTabMain) { clearUrl() } else if (isNewTabDeep) { updateUrl(tab, {}, activeTab) } } else { setTabParams(params) // Обновляем URL только для глубоких табов if (isNewTabDeep) { // Проверяем, была ли последняя запись в истории от модального окна const currentState = window.history.state || {} const isFromModal = currentState.modalOpen === true || currentState.conditionForm === true const isNavigatingToForm = tab === 'task-form' || tab === 'wishlist-form' || tab === 'shopping-item-form' if (isFromModal && isNavigatingToForm) { // Заменяем запись модального окна на запись формы редактирования // Используем replaceState вместо pushState, сохраняя activeTab как previousTab const url = new URL(window.location) url.searchParams.set('tab', tab) // Удаляем старые параметры const keysToRemove = [] url.searchParams.forEach((value, key) => { if (key !== 'tab') keysToRemove.push(key) }) keysToRemove.forEach(key => url.searchParams.delete(key)) // Добавляем новые параметры Object.entries(params).forEach(([key, value]) => { if (value !== undefined && value !== null) { url.searchParams.set(key, typeof value === 'object' ? JSON.stringify(value) : value) } }) window.history.replaceState({ tab, params, previousTab: activeTab }, '', url) } else { // Сохраняем текущий таб как предыдущий при переходе на глубокий таб updateUrl(tab, params, activeTab, options.replace) } } else if (isNewTabMain && isCurrentTabDeep) { // При переходе с глубокого таба на основной - очищаем URL и сохраняем таб в state clearUrl(tab) } else if (isNewTabMain && isCurrentTabMain) { // При переходе между основными табами - сохраняем таб в state без изменения URL, НЕ создаем новую запись в истории clearUrl(tab, false) } } } setActiveTab(tab) if (tab === 'current') { setSelectedProject(null) } else if (tab === 'full') { // Если переходим на full без selectedProject в params, очищаем выбранный проект if (!params.selectedProject) { setSelectedProject(null) } } // Обновляем список слов при возврате из экрана добавления слов if (activeTab === 'add-words' && tab === 'words') { setWordsRefreshTrigger(prev => prev + 1) } // Обновляем список задач при возврате из экрана редактирования или теста // Используем фоновую загрузку, чтобы не показывать индикатор загрузки if ((activeTab === 'task-form' || activeTab === 'test') && tab === 'tasks') { fetchTasksData(true) } // Сохраняем предыдущий таб при открытии wishlist-form или wishlist-detail if ((tab === 'wishlist-form' || tab === 'wishlist-detail') && activeTab !== tab) { setPreviousTab(activeTab) } // Обновляем список желаний при возврате из экрана редактирования if (activeTab === 'wishlist-form' && tab !== 'wishlist-form') { // Сохраняем boardId из параметров или текущих tabParams const savedBoardId = params.boardId || tabParams.boardId // Параметры уже установлены в строке 649, но мы можем их обновить, чтобы сохранить boardId if (savedBoardId && tab === 'wishlist') { setTabParams(prev => ({ ...prev, boardId: savedBoardId })) } if (tab === 'wishlist') { setWishlistRefreshTrigger(prev => prev + 1) } } // Обновляем список желаний при возврате из экрана детализации if (activeTab === 'wishlist-detail' && tab !== 'wishlist-detail') { if (tab === 'wishlist') { setWishlistRefreshTrigger(prev => prev + 1) } } // Сохраняем предыдущий таб при открытии shopping-item-form if (tab === 'shopping-item-form' && activeTab !== tab) { setPreviousTab(activeTab) setShoppingItemFormKey(prev => prev + 1) } // Обновляем список товаров при возврате из экрана редактирования if ((activeTab === 'shopping-item-form' || activeTab === 'shopping-board-form') && tab === 'shopping') { const savedBoardId = params.boardId || tabParams.boardId if (savedBoardId) { setTabParams(prev => ({ ...prev, boardId: savedBoardId })) } setShoppingRefreshTrigger(prev => prev + 1) } // Загрузка данных произойдет в useEffect при изменении activeTab } } // Обработчики для кнопки добавления задачи const handleAddClick = () => { setShowAddModal(true) } const handleAddTask = () => { setShowAddModal(false) handleNavigate('task-form', { taskId: undefined, isTest: false }) } const handleAddTest = () => { setShowAddModal(false) handleNavigate('task-form', { taskId: undefined, isTest: true }) } // Обработчик навигации для компонентов const handleNavigate = (tab, params = {}, options = {}) => { handleTabChange(tab, params, options) } // Загружаем данные при открытии таба (когда таб становится активным) useEffect(() => { if (!activeTab || !loadedTabs[activeTab]) return const isFirstLoad = !tabsInitializedRef.current[activeTab] const isReturningToTab = prevActiveTabRef.current !== null && prevActiveTabRef.current !== activeTab // Проверяем, не загружали ли мы уже этот таб в этом рендере const tabKey = `${activeTab}-${isFirstLoad ? 'first' : 'return'}` if (lastLoadedTabRef.current === tabKey) { return // Уже загружали } // Обновляем список слов при возврате из экрана добавления слов if (prevActiveTabRef.current === 'add-words' && activeTab === 'words') { setWordsRefreshTrigger(prev => prev + 1) } if (isFirstLoad) { // Первая загрузка таба lastLoadedTabRef.current = tabKey const projectName = activeTab === 'full' ? selectedProject : null loadTabData(activeTab, false, projectName) } else if (isReturningToTab) { // Возврат на таб - фоновая загрузка lastLoadedTabRef.current = tabKey const projectName = activeTab === 'full' ? selectedProject : null loadTabData(activeTab, true, projectName) } prevActiveTabRef.current = activeTab }, [activeTab, loadedTabs, loadTabData, selectedProject]) // Обновляем todayEntries при изменении selectedProject для таба 'full' // НЕ загружаем данные при открытии таба - это делает компонент FullStatistics // Загружаем только при изменении selectedProject, если таб уже открыт useEffect(() => { if (activeTab === 'full' && prevActiveTabRef.current === 'full') { // Таб уже был открыт, просто изменился selectedProject // Данные будут загружены компонентом FullStatistics с правильной датой } }, [selectedProject, activeTab]) // Определяем общее состояние загрузки и ошибок для кнопки Refresh const isAnyLoading = currentWeekLoading || fullStatisticsLoading || prioritiesLoading || isRefreshing const hasAnyError = currentWeekError || fullStatisticsError || prioritiesError // Сохраняем выбранный таб, чтобы восстановить его после перезагрузки useEffect(() => { try { window.localStorage?.setItem('activeTab', activeTab) } catch (err) { console.warn('Не удалось сохранить активный таб в localStorage', err) } }, [activeTab]) // Show loading while checking auth if (authLoading) { return (