import React, { useState, useEffect } from 'react' import { useAuth } from './auth/AuthContext' import LoadingError from './LoadingError' import Toast from './Toast' import './Integrations.css' function FitbitIntegration({ onNavigate }) { const { authFetch } = useAuth() const [connected, setConnected] = useState(false) const [loading, setLoading] = useState(true) const [error, setError] = useState('') const [message, setMessage] = useState('') const [oauthError, setOauthError] = useState('') const [toastMessage, setToastMessage] = useState(null) const [isLoadingError, setIsLoadingError] = useState(false) const [syncing, setSyncing] = useState(false) const [stats, setStats] = useState({ steps: { value: 0, goal: 10000 }, floors: { value: 0, goal: 10 } }) const [bindings, setBindings] = useState({ steps_task_id: null, floors_task_id: null, steps_goal_task_id: null, steps_goal_subtask_id: null, floors_goal_task_id: null, floors_goal_subtask_id: null }) const [editedBindings, setEditedBindings] = useState(bindings) const [savingStepsBindings, setSavingStepsBindings] = useState(false) const [savingFloorsBindings, setSavingFloorsBindings] = useState(false) const [tasks, setTasks] = useState([]) const [loadingTasks, setLoadingTasks] = useState(false) const [stepsGoalSubtasks, setStepsGoalSubtasks] = useState([]) const [floorsGoalSubtasks, setFloorsGoalSubtasks] = useState([]) const oauthStatusRef = React.useRef(null) useEffect(() => { const params = new URLSearchParams(window.location.search) const integration = params.get('integration') const status = params.get('status') if (integration === 'fitbit') { oauthStatusRef.current = status if (status === 'connected') { setMessage('Fitbit успешно подключен!') } else if (status === 'error') { const errorMsg = params.get('message') || 'unknown_error' const errorMessages = { 'config_error': 'Ошибка конфигурации сервера. Обратитесь к администратору.', 'invalid_state': 'Недействительный токен авторизации. Попробуйте ещё раз.', 'no_code': 'Не получен код авторизации от Fitbit. Попробуйте ещё раз.', 'token_exchange_failed': 'Не удалось обменять код на токен. Проверьте настройки Fitbit приложения.', 'user_info_failed': 'Не удалось получить информацию о пользователе Fitbit.', 'db_error': 'Ошибка сохранения данных. Попробуйте ещё раз.', 'unknown_error': 'Произошла неизвестная ошибка при подключении Fitbit.' } setOauthError(errorMessages[errorMsg] || `Ошибка: ${errorMsg}`) } window.history.replaceState({}, '', window.location.pathname) } checkStatus() }, []) useEffect(() => { if (connected) { loadStats() loadTasks() } }, [connected]) useEffect(() => { if (!editedBindings.steps_goal_task_id) { setStepsGoalSubtasks([]) return } let cancelled = false authFetch(`/api/tasks/${editedBindings.steps_goal_task_id}`) .then((r) => r.ok ? r.json() : null) .then((data) => { if (!cancelled && data?.subtasks) { setStepsGoalSubtasks(data.subtasks.map((s) => ({ id: s.task.id, name: s.task.name }))) } else if (!cancelled) { setStepsGoalSubtasks([]) } }) .catch(() => { if (!cancelled) setStepsGoalSubtasks([]) }) return () => { cancelled = true } }, [editedBindings.steps_goal_task_id, authFetch]) useEffect(() => { if (!editedBindings.floors_goal_task_id) { setFloorsGoalSubtasks([]) return } let cancelled = false authFetch(`/api/tasks/${editedBindings.floors_goal_task_id}`) .then((r) => r.ok ? r.json() : null) .then((data) => { if (!cancelled && data?.subtasks) { setFloorsGoalSubtasks(data.subtasks.map((s) => ({ id: s.task.id, name: s.task.name }))) } else if (!cancelled) { setFloorsGoalSubtasks([]) } }) .catch(() => { if (!cancelled) setFloorsGoalSubtasks([]) }) return () => { cancelled = true } }, [editedBindings.floors_goal_task_id, authFetch]) const checkStatus = async () => { try { setLoading(true) setError('') const response = await authFetch('/api/integrations/fitbit/status') if (!response.ok) { throw new Error('Ошибка при проверке статуса') } const data = await response.json() setConnected(data.connected || false) if (data.connected && data.bindings) { const b = data.bindings const normalized = { steps_task_id: b.steps_task_id ?? null, floors_task_id: b.floors_task_id ?? null, steps_goal_task_id: b.steps_goal_task_id ?? null, steps_goal_subtask_id: b.steps_goal_subtask_id ?? null, floors_goal_task_id: b.floors_goal_task_id ?? null, floors_goal_subtask_id: b.floors_goal_subtask_id ?? null } setBindings(normalized) setEditedBindings(normalized) } if (oauthStatusRef.current === 'connected' && !data.connected) { setOauthError('Авторизация в Fitbit прошла, но подключение не сохранилось. Попробуйте ещё раз.') setMessage('') } oauthStatusRef.current = null } catch (error) { console.error('Error checking status:', error) setError(error.message || 'Не удалось проверить статус') setIsLoadingError(true) } finally { setLoading(false) } } const loadStats = async () => { try { const response = await authFetch('/api/integrations/fitbit/stats') if (!response.ok) { throw new Error('Ошибка при загрузке статистики') } const data = await response.json() setStats({ steps: { value: data.steps?.value ?? 0, goal: data.steps?.goal ?? 10000 }, floors: { value: data.floors?.value ?? 0, goal: data.floors?.goal ?? 10 } }) } catch (error) { console.error('Error loading stats:', error) } } const loadTasks = async () => { try { setLoadingTasks(true) const response = await authFetch('/api/tasks') if (!response.ok) { throw new Error('Ошибка при загрузке задач') } const data = await response.json() setTasks(data || []) } catch (error) { console.error('Error loading tasks:', error) } finally { setLoadingTasks(false) } } const handleConnect = async () => { try { setLoading(true) setError('') const response = await authFetch('/api/integrations/fitbit/oauth/connect') if (!response.ok) { const errorData = await response.json().catch(() => ({})) throw new Error(errorData.error || 'Ошибка при подключении Fitbit') } const data = await response.json() if (data.auth_url) { window.location.href = data.auth_url } else { throw new Error('URL для авторизации не получен') } } catch (error) { console.error('Error connecting Fitbit:', error) setToastMessage({ text: error.message || 'Не удалось подключить Fitbit', type: 'error' }) setLoading(false) } } const handleDisconnect = async () => { if (!window.confirm('Вы уверены, что хотите отключить Fitbit?')) { return } try { setLoading(true) setError('') const response = await authFetch('/api/integrations/fitbit/disconnect', { method: 'DELETE', }) if (!response.ok) { const errorData = await response.json().catch(() => ({})) throw new Error(errorData.error || 'Ошибка при отключении') } setConnected(false) setStats({ steps: { value: 0, goal: 10000 }, floors: { value: 0, goal: 10 } }) setToastMessage({ text: 'Fitbit отключен', type: 'success' }) } catch (error) { console.error('Error disconnecting:', error) setToastMessage({ text: error.message || 'Не удалось отключить Fitbit', type: 'error' }) } finally { setLoading(false) } } const handleSync = async () => { try { setSyncing(true) const response = await authFetch('/api/integrations/fitbit/sync', { method: 'POST', }) if (!response.ok) { const errorData = await response.json().catch(() => ({})) throw new Error(errorData.error || 'Ошибка при синхронизации') } setToastMessage({ text: 'Данные синхронизированы', type: 'success' }) await loadStats() } catch (error) { console.error('Error syncing:', error) setToastMessage({ text: error.message || 'Не удалось синхронизировать данные', type: 'error' }) } finally { setSyncing(false) } } const handleSaveStepsBindings = async () => { try { setSavingStepsBindings(true) const response = await authFetch('/api/integrations/fitbit/bindings/steps', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ steps_task_id: editedBindings.steps_task_id, steps_goal_task_id: editedBindings.steps_goal_task_id, steps_goal_subtask_id: editedBindings.steps_goal_subtask_id }) }) if (!response.ok) { const errorData = await response.json().catch(() => ({})) throw new Error(errorData.error || 'Ошибка при сохранении привязок') } setBindings(prev => ({ ...prev, steps_task_id: editedBindings.steps_task_id, steps_goal_task_id: editedBindings.steps_goal_task_id, steps_goal_subtask_id: editedBindings.steps_goal_subtask_id })) setToastMessage({ text: 'Привязки шагов сохранены', type: 'success' }) } catch (error) { console.error('Error saving steps bindings:', error) setToastMessage({ text: error.message || 'Не удалось сохранить привязки', type: 'error' }) } finally { setSavingStepsBindings(false) } } const handleSaveFloorsBindings = async () => { try { setSavingFloorsBindings(true) const response = await authFetch('/api/integrations/fitbit/bindings/floors', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ floors_task_id: editedBindings.floors_task_id, floors_goal_task_id: editedBindings.floors_goal_task_id, floors_goal_subtask_id: editedBindings.floors_goal_subtask_id }) }) if (!response.ok) { const errorData = await response.json().catch(() => ({})) throw new Error(errorData.error || 'Ошибка при сохранении привязок') } setBindings(prev => ({ ...prev, floors_task_id: editedBindings.floors_task_id, floors_goal_task_id: editedBindings.floors_goal_task_id, floors_goal_subtask_id: editedBindings.floors_goal_subtask_id })) setToastMessage({ text: 'Привязки этажей сохранены', type: 'success' }) } catch (error) { console.error('Error saving floors bindings:', error) setToastMessage({ text: error.message || 'Не удалось сохранить привязки', type: 'error' }) } finally { setSavingFloorsBindings(false) } } const getProgressTasks = () => { return tasks.filter(t => t.has_progression || t.progression_base != null) } const getParentTasks = () => { return tasks.filter(t => (t.subtasks_count ?? 0) > 0) } const getProgressPercent = (value, goal) => { if (!goal || goal === 0) return 0 return Math.min(100, (value / goal) * 100) } const getProgressColor = (value, goal) => { if (value >= goal) return 'text-green-600' if (value >= goal * 0.5) return 'text-blue-600' return 'text-gray-600' } const isStepsBindingsDirty = (editedBindings.steps_task_id ?? null) !== (bindings.steps_task_id ?? null) || (editedBindings.steps_goal_task_id ?? null) !== (bindings.steps_goal_task_id ?? null) || (editedBindings.steps_goal_subtask_id ?? null) !== (bindings.steps_goal_subtask_id ?? null) const isFloorsBindingsDirty = (editedBindings.floors_task_id ?? null) !== (bindings.floors_task_id ?? null) || (editedBindings.floors_goal_task_id ?? null) !== (bindings.floors_goal_task_id ?? null) || (editedBindings.floors_goal_subtask_id ?? null) !== (bindings.floors_goal_subtask_id ?? null) if (isLoadingError && !loading) { return (
) } return (

Fitbit

{message && (

{message}

)} {loading ? (
) : connected ? (
{/* Группа: Шаги */}

Шаги

Сегодня {stats.steps.value.toLocaleString()} / {stats.steps.goal.toLocaleString()}

Задача

Достижение цели

{editedBindings.steps_goal_task_id && stepsGoalSubtasks.length > 0 && (
)}
{isStepsBindingsDirty && ( )}
{/* Группа: Этажи */}

Этажи

Сегодня {stats.floors.value} / {stats.floors.goal}

Задача

Достижение цели

{editedBindings.floors_goal_task_id && floorsGoalSubtasks.length > 0 && (
)}
{isFloorsBindingsDirty && ( )}

Как это работает

  • • Данные синхронизируются автоматически каждые 4 часа
  • • При синхронизации данные записываются в привязанные задачи
  • • Задачи автоматически выполняются в конце дня (23:55)
) : (
{oauthError && (

Ошибка подключения Fitbit

{oauthError}

)}

Подключение Fitbit

Подключите свой Fitbit аккаунт для автоматической синхронизации шагов и этажей с вашими задачами.

Что нужно сделать

  1. Нажмите кнопку "Подключить Fitbit"
  2. Авторизуйтесь в Fitbit
  3. Разрешите доступ к данным о физической активности
  4. Настройте привязки к задачам
)} {toastMessage && ( setToastMessage(null)} /> )}
) } export default FitbitIntegration