Files
play-life/play-life-web/src/components/FitbitIntegration.jsx
poignatov 2236f95ffa
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m13s
5.3.1: Обновление списка задач Fitbit при открытии меню
2026-02-24 15:50:41 +03:00

652 lines
27 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 } 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 (silent = false) => {
try {
if (!silent) setLoadingTasks(true)
const response = await authFetch('/api/tasks')
if (!response.ok) {
throw new Error('Ошибка при загрузке задач')
}
const data = await response.json()
setTasks(data || [])
} catch (error) {
if (!silent) console.error('Error loading tasks:', error)
} finally {
if (!silent) 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)
const handleClose = () => {
setEditedBindings(bindings)
onNavigate?.('profile')
}
if (isLoadingError && !loading) {
return (
<div className="p-4 md:p-6">
<button className="close-x-button" onClick={handleClose} title="Закрыть">
</button>
<LoadingError onRetry={checkStatus} />
</div>
)
}
return (
<div className="p-4 md:p-6">
<button className="close-x-button" onClick={handleClose} title="Закрыть">
</button>
<h1 className="text-2xl font-bold mb-6">Fitbit</h1>
{message && (
<div className="bg-green-50 border border-green-200 rounded-lg p-4 mb-6">
<p className="text-green-800">{message}</p>
<button onClick={() => setMessage('')} className="text-green-600 text-sm underline mt-2">Скрыть</button>
</div>
)}
{loading ? (
<div className="flex justify-center items-center h-32">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600"></div>
</div>
) : connected ? (
<div>
{/* Группа: Шаги */}
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
<h2 className="text-lg font-semibold mb-4">Шаги</h2>
<div className="space-y-4">
<div>
<div className="flex justify-between items-center mb-2">
<span className="text-gray-600 text-sm">Сегодня</span>
<span className={`font-bold ${getProgressColor(stats.steps.value, stats.steps.goal)}`}>
{stats.steps.value.toLocaleString()} / {stats.steps.goal.toLocaleString()}
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-3">
<div
className="bg-indigo-600 h-3 rounded-full transition-all"
style={{ width: `${getProgressPercent(stats.steps.value, stats.steps.goal)}%` }}
></div>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Прогресс шагов</label>
<p className="text-xs text-gray-500 mb-1">Задача</p>
<select
value={editedBindings.steps_task_id ?? ''}
onChange={(e) => setEditedBindings({
...editedBindings,
steps_task_id: e.target.value ? parseInt(e.target.value, 10) : null
})}
onFocus={() => loadTasks(true)}
disabled={loadingTasks}
className="w-full px-3 py-2 border border-gray-300 rounded-lg disabled:bg-gray-100"
>
<option value="">{loadingTasks ? 'Загрузка...' : 'Не выбрано'}</option>
{!loadingTasks && getProgressTasks().map(task => (
<option key={task.id} value={task.id}>{task.name}</option>
))}
</select>
</div>
<div>
<h3 className="text-sm font-medium text-gray-700 mb-2">Достижение цели</h3>
<div className="pl-0 space-y-2">
<div>
<label className="block text-sm text-gray-600 mb-1">Задача</label>
<select
value={editedBindings.steps_goal_task_id ?? ''}
onChange={(e) => setEditedBindings({
...editedBindings,
steps_goal_task_id: e.target.value ? parseInt(e.target.value, 10) : null,
steps_goal_subtask_id: null
})}
onFocus={() => loadTasks(true)}
disabled={loadingTasks}
className="w-full px-3 py-2 border border-gray-300 rounded-lg disabled:bg-gray-100"
>
<option value="">{loadingTasks ? 'Загрузка...' : 'Не выбрано'}</option>
{!loadingTasks && tasks.map(task => (
<option key={task.id} value={task.id}>{task.name}</option>
))}
</select>
</div>
{editedBindings.steps_goal_task_id && stepsGoalSubtasks.length > 0 && (
<div>
<label className="block text-sm text-gray-600 mb-1">Подзадача</label>
<select
value={editedBindings.steps_goal_subtask_id ?? ''}
onChange={(e) => setEditedBindings({
...editedBindings,
steps_goal_subtask_id: e.target.value ? parseInt(e.target.value, 10) : null
})}
disabled={loadingTasks}
className="w-full px-3 py-2 border border-gray-300 rounded-lg disabled:bg-gray-100"
>
<option value="">Не выбрано</option>
{stepsGoalSubtasks.map(subtask => (
<option key={subtask.id} value={subtask.id}>{subtask.name}</option>
))}
</select>
</div>
)}
</div>
</div>
{isStepsBindingsDirty && (
<button
onClick={handleSaveStepsBindings}
disabled={savingStepsBindings}
className="mt-3 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors disabled:opacity-50"
>
{savingStepsBindings ? 'Сохранение...' : 'Сохранить'}
</button>
)}
</div>
</div>
{/* Группа: Этажи */}
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
<h2 className="text-lg font-semibold mb-4">Этажи</h2>
<div className="space-y-4">
<div>
<div className="flex justify-between items-center mb-2">
<span className="text-gray-600 text-sm">Сегодня</span>
<span className={`font-bold ${getProgressColor(stats.floors.value, stats.floors.goal)}`}>
{stats.floors.value} / {stats.floors.goal}
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-3">
<div
className="bg-indigo-600 h-3 rounded-full transition-all"
style={{ width: `${getProgressPercent(stats.floors.value, stats.floors.goal)}%` }}
></div>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Прогресс этажей</label>
<p className="text-xs text-gray-500 mb-1">Задача</p>
<select
value={editedBindings.floors_task_id ?? ''}
onChange={(e) => setEditedBindings({
...editedBindings,
floors_task_id: e.target.value ? parseInt(e.target.value, 10) : null
})}
onFocus={() => loadTasks(true)}
disabled={loadingTasks}
className="w-full px-3 py-2 border border-gray-300 rounded-lg disabled:bg-gray-100"
>
<option value="">{loadingTasks ? 'Загрузка...' : 'Не выбрано'}</option>
{!loadingTasks && getProgressTasks().map(task => (
<option key={task.id} value={task.id}>{task.name}</option>
))}
</select>
</div>
<div>
<h3 className="text-sm font-medium text-gray-700 mb-2">Достижение цели</h3>
<div className="pl-0 space-y-2">
<div>
<label className="block text-sm text-gray-600 mb-1">Задача</label>
<select
value={editedBindings.floors_goal_task_id ?? ''}
onChange={(e) => setEditedBindings({
...editedBindings,
floors_goal_task_id: e.target.value ? parseInt(e.target.value, 10) : null,
floors_goal_subtask_id: null
})}
onFocus={() => loadTasks(true)}
disabled={loadingTasks}
className="w-full px-3 py-2 border border-gray-300 rounded-lg disabled:bg-gray-100"
>
<option value="">{loadingTasks ? 'Загрузка...' : 'Не выбрано'}</option>
{!loadingTasks && tasks.map(task => (
<option key={task.id} value={task.id}>{task.name}</option>
))}
</select>
</div>
{editedBindings.floors_goal_task_id && floorsGoalSubtasks.length > 0 && (
<div>
<label className="block text-sm text-gray-600 mb-1">Подзадача</label>
<select
value={editedBindings.floors_goal_subtask_id ?? ''}
onChange={(e) => setEditedBindings({
...editedBindings,
floors_goal_subtask_id: e.target.value ? parseInt(e.target.value, 10) : null
})}
disabled={loadingTasks}
className="w-full px-3 py-2 border border-gray-300 rounded-lg disabled:bg-gray-100"
>
<option value="">Не выбрано</option>
{floorsGoalSubtasks.map(subtask => (
<option key={subtask.id} value={subtask.id}>{subtask.name}</option>
))}
</select>
</div>
)}
</div>
</div>
{isFloorsBindingsDirty && (
<button
onClick={handleSaveFloorsBindings}
disabled={savingFloorsBindings}
className="mt-3 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors disabled:opacity-50"
>
{savingFloorsBindings ? 'Сохранение...' : 'Сохранить'}
</button>
)}
</div>
</div>
<button
onClick={handleSync}
disabled={syncing}
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors disabled:opacity-50 mb-6"
>
{syncing ? 'Синхронизация...' : 'Синхронизировать'}
</button>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-6 mb-6">
<h3 className="text-lg font-semibold mb-3 text-blue-900">Как это работает</h3>
<ul className="text-gray-700 space-y-2 text-sm">
<li> Данные синхронизируются автоматически каждые 4 часа</li>
<li> При синхронизации данные записываются в привязанные задачи</li>
<li> Задачи автоматически выполняются в конце дня (23:55)</li>
</ul>
</div>
<button
onClick={handleDisconnect}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
>
Отключить Fitbit
</button>
</div>
) : (
<div>
{oauthError && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
<p className="text-red-800 font-medium">Ошибка подключения Fitbit</p>
<p className="text-red-700 mt-1">{oauthError}</p>
<button onClick={() => setOauthError('')} className="text-red-600 text-sm underline mt-2">Скрыть</button>
</div>
)}
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
<h2 className="text-lg font-semibold mb-4">Подключение Fitbit</h2>
<p className="text-gray-700 mb-4">
Подключите свой Fitbit аккаунт для автоматической синхронизации шагов и этажей с вашими задачами.
</p>
<button
onClick={handleConnect}
className="px-6 py-3 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors font-semibold"
>
Подключить Fitbit
</button>
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-6">
<h3 className="text-lg font-semibold mb-3 text-blue-900">Что нужно сделать</h3>
<ol className="list-decimal list-inside space-y-2 text-gray-700">
<li>Нажмите кнопку "Подключить Fitbit"</li>
<li>Авторизуйтесь в Fitbit</li>
<li>Разрешите доступ к данным о физической активности</li>
<li>Настройте привязки к задачам</li>
</ol>
</div>
</div>
)}
{toastMessage && (
<Toast
message={toastMessage.text}
type={toastMessage.type}
onClose={() => setToastMessage(null)}
/>
)}
</div>
)
}
export default FitbitIntegration