Files
play-life/play-life-web/src/App.jsx
poignatov 64493b9c1f
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m19s
6.14.0: Еженедельное подтверждение приоритетов
2026-03-12 17:16:57 +03:00

1821 lines
80 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 ShoppingItemHistory from './components/ShoppingItemHistory'
import PurchaseScreen from './components/PurchaseScreen'
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', 'profile']
const deepTabs = ['add-words', 'test', 'purchase', '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', 'shopping-item-form', 'shopping-board-form', 'shopping-board-join', 'shopping-item-history']
/**
* Гарантирует базовую запись истории для главного экрана перед глубоким табом.
* После долгого бездействия 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,
'shopping-item-history': false,
purchase: 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,
'shopping-item-history': false,
purchase: false,
})
// Параметры для навигации между вкладками
const [tabParams, setTabParams] = useState({})
// Предыдущий таб для возврата из модальных окон
const [previousTab, setPreviousTab] = useState(null)
// Счётчик для сброса формы товара при каждом открытии
const [shoppingItemFormKey, setShoppingItemFormKey] = useState(0)
// Ref для функции открытия модала добавления записи в CurrentWeek
const currentWeekAddModalRef = useRef(null)
// Подтверждение приоритетов на текущей неделе (null = неизвестно, true/false = известно)
const [prioritiesConfirmed, setPrioritiesConfirmed] = useState(null)
const prioritiesOverlayPushedRef = useRef(false)
// Кеширование данных
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(() => {
const overlayVisible = activeTab === 'current' && prioritiesConfirmed === false
if (overlayVisible && !prioritiesOverlayPushedRef.current) {
prioritiesOverlayPushedRef.current = true
// Заменяем текущую запись { tab: 'current' } на { tab: 'tasks' },
// затем добавляем запись оверлея. Так системная кнопка "Назад" вернёт на tasks.
window.history.replaceState({ tab: 'tasks' }, '', '/')
window.history.pushState({ tab: 'current', prioritiesOverlay: true }, '', '/')
}
if (!overlayVisible) {
prioritiesOverlayPushedRef.current = false
}
}, [activeTab, prioritiesConfirmed])
// Переключение на экран прогрессии после успешной авторизации
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)
setPrioritiesConfirmed(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', 'purchase', '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', 'shopping-item-history']
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 }))
// Сохраняем таб в history state для корректной работы кнопки "назад"
window.history.replaceState({ tab: savedTab }, '', window.location.href)
} else {
// Если нет сохранённого таба — активируем current по умолчанию
setLoadedTabs(prev => ({ ...prev, current: true }))
window.history.replaceState({ tab: 'current' }, '', window.location.href)
}
// Очищаем 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 : {}
const rootData = (Array.isArray(jsonData) && jsonData.length > 0) ? jsonData[0] : jsonData
const prioritiesConfirmedValue = rootData?.priorities_confirmed ?? null
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
})
if (prioritiesConfirmedValue !== null) {
setPrioritiesConfirmed(prioritiesConfirmedValue)
}
} 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', 'purchase', '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', 'shopping-item-history']
// Проверяем 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 && mainTabs.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 (возврат после создания)
const isTaskFormWithNoParams = tab === 'task-form' && params.taskId === undefined && params.wishlistId === undefined && params.returnTo === 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' || activeTab === 'purchase') && 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 = () => {
handleNavigate('task-form', { taskId: undefined })
}
// Обработчик навигации для компонентов
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 (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-900 via-purple-900 to-slate-900">
<div className="text-white text-xl">Загрузка...</div>
</div>
)
}
// Show auth screen if not authenticated
if (!isAuthenticated) {
prevIsAuthenticatedRef.current = false
return <AuthScreen />
}
// Определяем, нужно ли скрывать нижнюю панель (для fullscreen экранов)
const isFullscreenTab = activeTab === 'test' || activeTab === 'purchase' || activeTab === 'add-words' || activeTab === 'task-form' || activeTab === 'wishlist-form' || activeTab === 'wishlist-detail' || activeTab === 'todoist-integration' || activeTab === 'telegram-integration' || activeTab === 'fitbit-integration' || activeTab === 'full' || activeTab === 'priorities' || activeTab === 'words' || activeTab === 'dictionaries' || activeTab === 'tracking' || activeTab === 'tracking-access' || activeTab === 'tracking-invite' || activeTab === 'shopping' || activeTab === 'shopping-item-form' || activeTab === 'shopping-board-form' || activeTab === 'shopping-board-join' || activeTab === 'shopping-item-history'
// Функция для получения классов скролл-контейнера для каждого таба
// Каждый таб имеет свой изолированный скролл-контейнер для автоматического сохранения позиции скролла
const getTabContainerClasses = (tabName) => {
const isActive = activeTab === tabName
const baseClasses = 'absolute inset-0 overflow-y-auto'
// Активный таб: z-10 (сверху), неактивные: z-0 + invisible + opacity-0 (мгновенное скрытие)
const visibilityClasses = isActive ? 'z-10' : 'z-0 invisible opacity-0 pointer-events-none'
// Определяем padding для каждого таба
let paddingClasses = ''
if (tabName === 'current' || tabName === 'tasks' || tabName === 'wishlist' || tabName === 'profile') {
paddingClasses = 'pb-20'
} else if (tabName === 'priorities') {
paddingClasses = 'pb-20'
} else if (tabName === 'words' || tabName === 'dictionaries' || tabName === 'shopping') {
paddingClasses = 'pb-16'
}
return `${baseClasses} ${paddingClasses} ${visibilityClasses}`.trim()
}
// Функция для определения отступов внутреннего контейнера
const getInnerContainerClasses = (tabName) => {
if (tabName === 'tasks' || tabName === 'wishlist' || tabName === 'profile') {
return 'max-w-7xl mx-auto p-4 md:p-8'
}
if (tabName === 'current') {
return 'max-w-7xl mx-auto p-4 md:p-6'
}
if (tabName === 'full' || tabName === 'priorities' || tabName === 'dictionaries' || tabName === 'words' || tabName === 'shopping' || tabName === 'shopping-item-history' || tabName === 'purchase') {
return 'max-w-7xl mx-auto px-4 md:px-8 py-0'
}
// Fullscreen табы без отступов
return 'max-w-7xl mx-auto p-0'
}
return (
<div className="flex flex-col h-screen h-dvh overflow-hidden">
{/* Контейнер табов - каждый таб имеет свой изолированный скролл */}
<div className="flex-1 relative overflow-hidden">
{loadedTabs.current && (
<div className={getTabContainerClasses('current')}>
<div className={getInnerContainerClasses('current')}>
<CurrentWeek
onProjectClick={handleProjectClick}
data={currentWeekData}
loading={currentWeekLoading}
error={currentWeekError}
onRetry={fetchCurrentWeekData}
allProjectsData={fullStatisticsData}
onNavigate={handleNavigate}
onOpenAddModal={(setOpenFn) => {
currentWeekAddModalRef.current = setOpenFn
}}
/>
</div>
</div>
)}
{loadedTabs.priorities && (
<div className={getTabContainerClasses('priorities')}>
<div className={getInnerContainerClasses('priorities')}>
<ProjectPriorityManager
allProjectsData={fullStatisticsData}
currentWeekData={currentWeekData}
shouldLoad={activeTab === 'priorities' && loadedTabs.priorities}
onLoadingChange={setPrioritiesLoading}
onErrorChange={setPrioritiesError}
refreshTrigger={prioritiesRefreshTrigger}
onNavigate={handleNavigate}
onConfirmed={async () => {
await fetchCurrentWeekData(false)
setPrioritiesConfirmed(true)
markTabAsLoaded('current')
setActiveTab('current')
}}
/>
</div>
</div>
)}
{loadedTabs.full && (
<div className={getTabContainerClasses('full')}>
<div className={getInnerContainerClasses('full')}>
<FullStatistics
selectedProject={selectedProject}
onClearSelection={() => {
setSelectedProject(null)
setTabParams({})
replaceUrl('full', {})
}}
data={fullStatisticsData}
loading={fullStatisticsLoading}
error={fullStatisticsError}
todayEntries={todayEntriesData}
todayEntriesLoading={todayEntriesLoading || todayEntriesBackgroundLoading}
todayEntriesError={todayEntriesError}
onRetryTodayEntries={() => fetchTodayEntries(false, selectedProject, null)}
fetchTodayEntries={fetchTodayEntries}
onRetry={fetchFullStatisticsData}
currentWeekData={currentWeekData}
onNavigate={handleNavigate}
activeTab={activeTab}
/>
</div>
</div>
)}
{loadedTabs.words && (
<div className={getTabContainerClasses('words')}>
<div className={getInnerContainerClasses('words')}>
<WordList
onNavigate={handleNavigate}
dictionaryId={tabParams.dictionaryId}
isNewDictionary={tabParams.isNewDictionary}
refreshTrigger={wordsRefreshTrigger}
/>
</div>
</div>
)}
{loadedTabs['add-words'] && (
<div className={getTabContainerClasses('add-words')}>
<div className={getInnerContainerClasses('add-words')}>
<AddWords
onNavigate={handleNavigate}
dictionaryId={tabParams.dictionaryId}
dictionaryName={tabParams.dictionaryName}
/>
</div>
</div>
)}
{loadedTabs.dictionaries && (
<div className={getTabContainerClasses('dictionaries')}>
<div className={getInnerContainerClasses('dictionaries')}>
<DictionaryList
onNavigate={handleNavigate}
refreshTrigger={dictionariesRefreshTrigger}
/>
</div>
</div>
)}
{loadedTabs.test && (
<div className={getTabContainerClasses('test')}>
<div className={getInnerContainerClasses('test')}>
<TestWords
onNavigate={handleNavigate}
wordCount={tabParams.wordCount}
configId={tabParams.configId}
maxCards={tabParams.maxCards}
taskId={tabParams.taskId}
/>
</div>
</div>
)}
{loadedTabs.purchase && (
<div className={getTabContainerClasses('purchase')}>
<div className={getInnerContainerClasses('purchase')}>
<PurchaseScreen
onNavigate={handleNavigate}
purchaseConfigId={tabParams.purchaseConfigId}
taskId={tabParams.taskId}
taskName={tabParams.taskName}
/>
</div>
</div>
)}
{loadedTabs.tasks && (
<div className={getTabContainerClasses('tasks')}>
<div className={getInnerContainerClasses('tasks')}>
<TaskList
onNavigate={handleNavigate}
data={tasksData}
loading={tasksLoading}
backgroundLoading={tasksBackgroundLoading}
error={tasksError}
onRetry={() => fetchTasksData(false)}
onRefresh={(isBackground = false) => fetchTasksData(isBackground)}
/>
</div>
</div>
)}
{loadedTabs['task-form'] && (
<div className={getTabContainerClasses('task-form')}>
<div className={getInnerContainerClasses('task-form')}>
<TaskForm
key={tabParams.taskId || 'new-task'}
onNavigate={handleNavigate}
taskId={tabParams.taskId}
wishlistId={tabParams.wishlistId}
returnTo={tabParams.returnTo}
returnWishlistId={tabParams.returnWishlistId}
/>
</div>
</div>
)}
{loadedTabs.wishlist && (
<div className={getTabContainerClasses('wishlist')}>
<div className={getInnerContainerClasses('wishlist')}>
<Wishlist
onNavigate={handleNavigate}
refreshTrigger={wishlistRefreshTrigger}
isActive={activeTab === 'wishlist'}
initialBoardId={tabParams.boardId}
boardDeleted={tabParams.boardDeleted}
/>
</div>
</div>
)}
{loadedTabs['wishlist-form'] && (
<div className={getTabContainerClasses('wishlist-form')}>
<div className={getInnerContainerClasses('wishlist-form')}>
<WishlistForm
key={`${tabParams.wishlistId || 'new'}-${tabParams.editConditionIndex ?? ''}-${tabParams.newTaskId ?? ''}-${tabParams.boardId ?? ''}`}
onNavigate={handleNavigate}
wishlistId={tabParams.wishlistId}
editConditionIndex={tabParams.editConditionIndex}
newTaskId={tabParams.newTaskId}
boardId={tabParams.boardId}
/>
</div>
</div>
)}
{loadedTabs['board-form'] && (
<div className={getTabContainerClasses('board-form')}>
<div className={getInnerContainerClasses('board-form')}>
<BoardForm
key={tabParams.boardId || 'new'}
onNavigate={handleNavigate}
boardId={tabParams.boardId}
onSaved={() => setWishlistRefreshTrigger(prev => prev + 1)}
/>
</div>
</div>
)}
{loadedTabs['board-join'] && (
<div className={getTabContainerClasses('board-join')}>
<div className={getInnerContainerClasses('board-join')}>
<BoardJoinPreview
key={tabParams.inviteToken}
onNavigate={handleNavigate}
inviteToken={tabParams.inviteToken}
/>
</div>
</div>
)}
{loadedTabs.shopping && (
<div className={getTabContainerClasses('shopping')}>
<div className={getInnerContainerClasses('shopping')}>
<ShoppingList
onNavigate={handleNavigate}
refreshTrigger={shoppingRefreshTrigger}
isActive={activeTab === 'shopping'}
initialBoardId={tabParams.boardId}
boardDeleted={tabParams.boardDeleted}
/>
</div>
</div>
)}
{loadedTabs['shopping-item-form'] && (
<div className={getTabContainerClasses('shopping-item-form')}>
<div className={getInnerContainerClasses('shopping-item-form')}>
<ShoppingItemForm
key={tabParams.itemId || `new-${shoppingItemFormKey}`}
onNavigate={handleNavigate}
itemId={tabParams.itemId}
boardId={tabParams.boardId}
onSaved={() => setShoppingRefreshTrigger(prev => prev + 1)}
/>
</div>
</div>
)}
{loadedTabs['shopping-board-form'] && (
<div className={getTabContainerClasses('shopping-board-form')}>
<div className={getInnerContainerClasses('shopping-board-form')}>
<ShoppingBoardForm
key={tabParams.boardId || 'new'}
onNavigate={handleNavigate}
boardId={tabParams.boardId}
onSaved={() => setShoppingRefreshTrigger(prev => prev + 1)}
/>
</div>
</div>
)}
{loadedTabs['shopping-board-join'] && (
<div className={getTabContainerClasses('shopping-board-join')}>
<div className={getInnerContainerClasses('shopping-board-join')}>
<ShoppingBoardJoinPreview
key={tabParams.inviteToken}
onNavigate={handleNavigate}
inviteToken={tabParams.inviteToken}
/>
</div>
</div>
)}
{loadedTabs['shopping-item-history'] && (
<div className={getTabContainerClasses('shopping-item-history')}>
<div className={getInnerContainerClasses('shopping-item-history')}>
<ShoppingItemHistory
key={tabParams.itemId}
itemId={tabParams.itemId}
onNavigate={handleNavigate}
/>
</div>
</div>
)}
{loadedTabs.profile && (
<div className={getTabContainerClasses('profile')}>
<div className={getInnerContainerClasses('profile')}>
<Profile onNavigate={handleNavigate} />
</div>
</div>
)}
{loadedTabs['todoist-integration'] && (
<div className={getTabContainerClasses('todoist-integration')}>
<div className={getInnerContainerClasses('todoist-integration')}>
<TodoistIntegration onNavigate={handleNavigate} />
</div>
</div>
)}
{loadedTabs['telegram-integration'] && (
<div className={getTabContainerClasses('telegram-integration')}>
<div className={getInnerContainerClasses('telegram-integration')}>
<TelegramIntegration onNavigate={handleNavigate} />
</div>
</div>
)}
{loadedTabs['fitbit-integration'] && (
<div className={getTabContainerClasses('fitbit-integration')}>
<div className={getInnerContainerClasses('fitbit-integration')}>
<FitbitIntegration onNavigate={handleNavigate} />
</div>
</div>
)}
{loadedTabs.tracking && (
<div className={getTabContainerClasses('tracking')}>
<div className={getInnerContainerClasses('tracking')}>
<Tracking
onNavigate={handleNavigate}
activeTab={activeTab}
/>
</div>
</div>
)}
{loadedTabs['tracking-access'] && (
<div className={getTabContainerClasses('tracking-access')}>
<div className={getInnerContainerClasses('tracking-access')}>
<TrackingAccess onNavigate={handleNavigate} activeTab={activeTab} />
</div>
</div>
)}
{loadedTabs['tracking-invite'] && (
<div className={getTabContainerClasses('tracking-invite')}>
<div className={getInnerContainerClasses('tracking-invite')}>
<TrackingInviteAccept
inviteToken={tabParams.inviteToken}
onNavigate={handleNavigate}
/>
</div>
</div>
)}
</div>
{/* Кнопка добавления задачи (только для таба задач) */}
{!isFullscreenTab && activeTab === 'tasks' && (
<button
onClick={handleAddClick}
className="fixed bottom-16 right-4 z-20 bg-gradient-to-r from-indigo-600 to-purple-600 hover:from-indigo-700 hover:to-purple-700 text-white w-[61px] h-[61px] rounded-2xl shadow-lg transition-all duration-200 hover:scale-105 flex items-center justify-center"
title="Добавить задачу"
>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M12 5v14M5 12h14" />
</svg>
</button>
)}
{/* Кнопка добавления желания (только для таба wishlist) */}
{!isFullscreenTab && activeTab === 'wishlist' && (
<button
onClick={() => {
// Получаем boardId из tabParams или из localStorage (где его сохраняет компонент Wishlist)
let boardId = tabParams.boardId
if (!boardId) {
try {
const saved = localStorage.getItem('wishlist_selected_board_id')
if (saved) {
const parsed = parseInt(saved, 10)
if (!isNaN(parsed)) boardId = parsed
}
} catch (err) {
console.error('Error loading boardId from localStorage:', err)
}
}
handleNavigate('wishlist-form', { wishlistId: undefined, boardId: boardId })
}}
className="fixed bottom-16 right-4 z-20 bg-gradient-to-r from-indigo-600 to-purple-600 hover:from-indigo-700 hover:to-purple-700 text-white w-[61px] h-[61px] rounded-2xl shadow-lg transition-all duration-200 hover:scale-105 flex items-center justify-center"
title="Добавить желание"
>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M12 5v14M5 12h14" />
</svg>
</button>
)}
{/* Кнопка добавления товара (только для таба shopping) */}
{activeTab === 'shopping' && (
<button
onClick={() => {
let boardId = tabParams.boardId
if (!boardId) {
try {
const saved = localStorage.getItem('shopping_selected_board_id')
if (saved) {
const parsed = parseInt(saved, 10)
if (!isNaN(parsed)) boardId = parsed
}
} catch (err) {
console.error('Error loading boardId from localStorage:', err)
}
}
handleNavigate('shopping-item-form', { itemId: undefined, boardId: boardId })
}}
className="fixed bottom-4 right-4 z-20 bg-gradient-to-r from-indigo-600 to-purple-600 hover:from-indigo-700 hover:to-purple-700 text-white w-[61px] h-[61px] rounded-2xl shadow-lg transition-all duration-200 hover:scale-105 flex items-center justify-center"
title="Добавить товар"
>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M12 5v14M5 12h14" />
</svg>
</button>
)}
{/* Кнопка добавления записи (только для таба current - экран прогресса) */}
{!isFullscreenTab && activeTab === 'current' && (
<button
onClick={() => {
if (currentWeekAddModalRef.current) {
currentWeekAddModalRef.current()
}
}}
className="fixed bottom-16 right-4 z-20 bg-gradient-to-r from-indigo-600 to-purple-600 hover:from-indigo-700 hover:to-purple-700 text-white w-[61px] h-[61px] rounded-2xl shadow-lg transition-all duration-200 hover:scale-105 flex items-center justify-center"
title="Добавить запись"
>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M12 5v14M5 12h14" />
</svg>
</button>
)}
{/* Кнопка добавления словаря (только для таба dictionaries) */}
{activeTab === 'dictionaries' && (
<button
onClick={() => handleNavigate('words', { dictionaryId: null, isNewDictionary: true })}
className="fixed bottom-4 right-4 z-20 bg-gradient-to-r from-indigo-600 to-purple-600 hover:from-indigo-700 hover:to-purple-700 text-white w-[61px] h-[61px] rounded-2xl shadow-lg transition-all duration-200 hover:scale-105 flex items-center justify-center"
title="Добавить словарь"
>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M12 5v14M5 12h14" />
</svg>
</button>
)}
{/* Кнопка добавления слов (только для таба words) */}
{activeTab === 'words' && (
<button
onClick={() => {
handleNavigate('add-words', { dictionaryId: tabParams.dictionaryId, dictionaryName: tabParams.dictionaryName })
}}
className="fixed bottom-4 right-4 z-20 bg-gradient-to-r from-indigo-600 to-purple-600 hover:from-indigo-700 hover:to-purple-700 text-white w-[61px] h-[61px] rounded-2xl shadow-lg transition-all duration-200 hover:scale-105 flex items-center justify-center"
title="Добавить слова"
>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M12 5v14M5 12h14" />
</svg>
</button>
)}
{!isFullscreenTab && (
<div className="fixed bottom-0 left-0 right-0 flex bg-white/90 backdrop-blur-md border-t border-white/20 flex-shrink-0 overflow-x-auto shadow-lg z-10 justify-center items-center w-full" style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}>
<div className="flex">
<button
onClick={() => handleTabChange('current')}
className={`min-w-max whitespace-nowrap px-4 py-4 transition-all duration-300 relative ${
activeTab === 'current'
? 'text-indigo-700 bg-white/50'
: 'text-gray-600 hover:text-indigo-600 hover:bg-white/30'
}`}
title="Неделя"
>
<span className="relative z-10 flex items-center justify-center">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
<line x1="16" y1="2" x2="16" y2="6"></line>
<line x1="8" y1="2" x2="8" y2="6"></line>
<line x1="3" y1="10" x2="21" y2="10"></line>
</svg>
</span>
{activeTab === 'current' && (
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-indigo-500 to-purple-500"></div>
)}
</button>
<button
onClick={() => handleTabChange('tasks')}
className={`min-w-max whitespace-nowrap px-4 py-4 transition-all duration-300 relative ${
activeTab === 'tasks' || activeTab === 'task-form'
? 'text-indigo-700 bg-white/50'
: 'text-gray-600 hover:text-indigo-600 hover:bg-white/30'
}`}
title="Задачи"
>
<span className="relative z-10 flex items-center justify-center">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 11l3 3L22 4"></path>
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"></path>
</svg>
</span>
{(activeTab === 'tasks' || activeTab === 'task-form') && (
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-indigo-500 to-purple-500"></div>
)}
</button>
<button
onClick={() => handleTabChange('wishlist')}
className={`min-w-max whitespace-nowrap px-4 py-4 transition-all duration-300 relative ${
activeTab === 'wishlist' || activeTab === 'wishlist-form'
? 'text-indigo-700 bg-white/50'
: 'text-gray-600 hover:text-indigo-600 hover:bg-white/30'
}`}
title="Желания"
>
<span className="relative z-10 flex items-center justify-center">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 12 20 22 4 22 4 12"></polyline>
<rect x="2" y="7" width="20" height="5"></rect>
<line x1="12" y1="22" x2="12" y2="7"></line>
<path d="M12 7H7.5a2.5 2.5 0 0 1 0-5C11 2 12 7 12 7z"></path>
<path d="M12 7h4.5a2.5 2.5 0 0 0 0-5C13 2 12 7 12 7z"></path>
</svg>
</span>
{(activeTab === 'wishlist' || activeTab === 'wishlist-form') && (
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-indigo-500 to-purple-500"></div>
)}
</button>
<button
onClick={() => handleTabChange('profile')}
className={`min-w-max whitespace-nowrap px-4 py-4 transition-all duration-300 relative ${
activeTab === 'profile'
? 'text-indigo-700 bg-white/50'
: 'text-gray-600 hover:text-indigo-600 hover:bg-white/30'
}`}
title="Профиль"
>
<span className="relative z-10 flex items-center justify-center">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
<circle cx="12" cy="7" r="4"></circle>
</svg>
</span>
{activeTab === 'profile' && (
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-indigo-500 to-purple-500"></div>
)}
</button>
</div>
</div>
)}
{/* Оверлей подтверждения приоритетов — показывается поверх экрана прогресса недели */}
{activeTab === 'current' && prioritiesConfirmed === false && (
<div className="fixed inset-0 bg-white z-50 overflow-y-auto">
<div className="max-w-2xl mx-auto px-4 h-full">
<ProjectPriorityManager
allProjectsData={fullStatisticsData}
currentWeekData={currentWeekData}
shouldLoad={true}
onLoadingChange={setPrioritiesLoading}
onErrorChange={setPrioritiesError}
refreshTrigger={Math.max(prioritiesRefreshTrigger, 1)}
onNavigate={handleNavigate}
onConfirmed={async () => {
await fetchCurrentWeekData(false)
setPrioritiesConfirmed(true)
}}
onClose={() => {
// history.back() переходит к { tab: 'tasks' }, popstate обработает переключение
window.history.back()
}}
/>
</div>
</div>
)}
</div>
)
}
function App() {
return (
<AuthProvider>
<AppContent />
<PWAUpdatePrompt />
</AuthProvider>
)
}
export default App