Добавлена интеграция с Fitbit
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m25s

This commit is contained in:
poignatov
2026-02-06 20:50:49 +03:00
parent f1c590de43
commit dfccba4e55
13 changed files with 1711 additions and 21 deletions

BIN
.DS_Store vendored

Binary file not shown.

View File

@@ -1 +1 @@
4.26.0 4.26.1

View File

@@ -62,6 +62,26 @@ TODOIST_CLIENT_SECRET=
# Получить в Developer Console: "Client secret for webhooks" # Получить в Developer Console: "Client secret for webhooks"
TODOIST_WEBHOOK_SECRET= TODOIST_WEBHOOK_SECRET=
# ============================================
# Fitbit Integration Configuration
# ============================================
# Fitbit приложение для интеграции с Play Life
# Настроить в: https://dev.fitbit.com/apps
#
# В настройках Fitbit приложения указать:
# - OAuth 2.0 Application Type: Server
# - Callback URL: <WEBHOOK_BASE_URL>/api/integrations/fitbit/oauth/callback
# - Default Access Type: Read-Only
# - Scopes: activity, profile
# - Terms of Service URL: <WEBHOOK_BASE_URL>/terms
# - Privacy Policy URL: <WEBHOOK_BASE_URL>/privacy
# Client ID Fitbit приложения
FITBIT_CLIENT_ID=
# Client Secret Fitbit приложения
FITBIT_CLIENT_SECRET=
# ============================================ # ============================================
# Authentication Configuration # Authentication Configuration
# ============================================ # ============================================

View File

@@ -3816,6 +3816,72 @@ func (a *App) startDailyReportScheduler() {
// Планировщик будет работать в фоновом режиме // Планировщик будет работать в фоновом режиме
} }
// startFitbitSyncScheduler запускает планировщик для синхронизации данных Fitbit каждые 4 часа
func (a *App) startFitbitSyncScheduler() {
// Создаем планировщик в UTC (синхронизация не зависит от часового пояса пользователя)
c := cron.New(cron.WithLocation(time.UTC))
// Добавляем задачу: каждые 4 часа
// Cron выражение: "0 */4 * * *" означает: минута=0, каждый 4-й час, любой день месяца, любой месяц, любой день недели
_, err := c.AddFunc("0 */4 * * *", func() {
log.Printf("Scheduled task: Syncing Fitbit data for all users")
if err := a.syncFitbitDataForAllUsers(); err != nil {
log.Printf("Error in scheduled Fitbit sync: %v", err)
}
})
if err != nil {
log.Printf("Error adding cron job for Fitbit sync: %v", err)
return
}
// Запускаем планировщик
c.Start()
log.Printf("Fitbit sync scheduler started: every 4 hours")
// Планировщик будет работать в фоновом режиме
}
// syncFitbitDataForAllUsers синхронизирует данные Fitbit для всех подключенных пользователей
func (a *App) syncFitbitDataForAllUsers() error {
rows, err := a.DB.Query(`
SELECT user_id FROM fitbit_integrations
WHERE access_token IS NOT NULL
`)
if err != nil {
return fmt.Errorf("failed to get users: %w", err)
}
defer rows.Close()
var userIDs []int
for rows.Next() {
var userID int
if err := rows.Scan(&userID); err != nil {
log.Printf("Error scanning user_id: %v", err)
continue
}
userIDs = append(userIDs, userID)
}
if err := rows.Err(); err != nil {
return fmt.Errorf("error iterating users: %w", err)
}
log.Printf("Syncing Fitbit data for %d users", len(userIDs))
// Синхронизируем данные за сегодня для каждого пользователя
today := time.Now()
for _, userID := range userIDs {
if err := a.syncFitbitData(userID, today); err != nil {
log.Printf("Failed to sync Fitbit data for user_id=%d: %v", userID, err)
// Продолжаем синхронизацию для остальных пользователей
continue
}
}
return nil
}
// startEndOfDayTaskScheduler запускает планировщик для автовыполнения задач в конце дня // startEndOfDayTaskScheduler запускает планировщик для автовыполнения задач в конце дня
// каждый день в 23:55 в указанном часовом поясе // каждый день в 23:55 в указанном часовом поясе
func (a *App) startEndOfDayTaskScheduler() { func (a *App) startEndOfDayTaskScheduler() {
@@ -4078,6 +4144,9 @@ func main() {
// Запускаем планировщик для автовыполнения задач в конце дня в 23:55 // Запускаем планировщик для автовыполнения задач в конце дня в 23:55
app.startEndOfDayTaskScheduler() app.startEndOfDayTaskScheduler()
// Запускаем планировщик синхронизации Fitbit каждые 4 часа
app.startFitbitSyncScheduler()
r := mux.NewRouter() r := mux.NewRouter()
// Public auth routes (no authentication required) // Public auth routes (no authentication required)
@@ -4172,6 +4241,15 @@ func main() {
protected.HandleFunc("/api/integrations/todoist/status", app.getTodoistStatusHandler).Methods("GET", "OPTIONS") protected.HandleFunc("/api/integrations/todoist/status", app.getTodoistStatusHandler).Methods("GET", "OPTIONS")
protected.HandleFunc("/api/integrations/todoist/disconnect", app.todoistDisconnectHandler).Methods("DELETE", "OPTIONS") protected.HandleFunc("/api/integrations/todoist/disconnect", app.todoistDisconnectHandler).Methods("DELETE", "OPTIONS")
// Fitbit OAuth endpoints
protected.HandleFunc("/api/integrations/fitbit/oauth/connect", app.fitbitOAuthConnectHandler).Methods("GET")
r.HandleFunc("/api/integrations/fitbit/oauth/callback", app.fitbitOAuthCallbackHandler).Methods("GET") // Публичный!
protected.HandleFunc("/api/integrations/fitbit/status", app.getFitbitStatusHandler).Methods("GET", "OPTIONS")
protected.HandleFunc("/api/integrations/fitbit/disconnect", app.fitbitDisconnectHandler).Methods("DELETE", "OPTIONS")
protected.HandleFunc("/api/integrations/fitbit/goals", app.updateFitbitGoalsHandler).Methods("PUT", "OPTIONS")
protected.HandleFunc("/api/integrations/fitbit/sync", app.fitbitSyncHandler).Methods("POST", "OPTIONS")
protected.HandleFunc("/api/integrations/fitbit/stats", app.getFitbitStatsHandler).Methods("GET", "OPTIONS")
// Tasks // Tasks
protected.HandleFunc("/api/tasks", app.getTasksHandler).Methods("GET", "OPTIONS") protected.HandleFunc("/api/tasks", app.getTasksHandler).Methods("GET", "OPTIONS")
protected.HandleFunc("/api/tasks", app.createTaskHandler).Methods("POST", "OPTIONS") protected.HandleFunc("/api/tasks", app.createTaskHandler).Methods("POST", "OPTIONS")
@@ -10059,6 +10137,732 @@ func (a *App) todoistDisconnectHandler(w http.ResponseWriter, r *http.Request) {
}) })
} }
// ============================================
// Fitbit OAuth handlers
// ============================================
// generateFitbitOAuthState генерирует JWT state для Fitbit OAuth
func generateFitbitOAuthState(userID int, jwtSecret []byte) (string, error) {
claims := OAuthStateClaims{
UserID: userID,
Type: "fitbit_oauth",
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)), // 1 день
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(jwtSecret)
}
// validateFitbitOAuthState проверяет и извлекает user_id из JWT state для Fitbit
func validateFitbitOAuthState(stateString string, jwtSecret []byte) (int, error) {
token, err := jwt.ParseWithClaims(stateString, &OAuthStateClaims{}, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return jwtSecret, nil
})
if err != nil {
return 0, err
}
claims, ok := token.Claims.(*OAuthStateClaims)
if !ok || !token.Valid {
return 0, fmt.Errorf("invalid token")
}
if claims.Type != "fitbit_oauth" {
return 0, fmt.Errorf("wrong token type")
}
return claims.UserID, nil
}
// exchangeFitbitCodeForToken обменивает OAuth code на access_token и refresh_token для Fitbit
func exchangeFitbitCodeForToken(code, redirectURI, clientID, clientSecret string) (accessToken, refreshToken string, expiresIn int, err error) {
data := url.Values{}
data.Set("grant_type", "authorization_code")
data.Set("code", code)
data.Set("redirect_uri", redirectURI)
req, err := http.NewRequest("POST", "https://api.fitbit.com/oauth2/token", strings.NewReader(data.Encode()))
if err != nil {
return "", "", 0, fmt.Errorf("failed to create request: %w", err)
}
// Fitbit требует Basic Auth для Server приложений
auth := base64.StdEncoding.EncodeToString([]byte(clientID + ":" + clientSecret))
req.Header.Set("Authorization", "Basic "+auth)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return "", "", 0, fmt.Errorf("failed to exchange code: %w", err)
}
defer resp.Body.Close()
bodyBytes, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return "", "", 0, fmt.Errorf("token exchange failed (status %d): %s", resp.StatusCode, string(bodyBytes))
}
var result struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int `json:"expires_in"`
TokenType string `json:"token_type"`
UserID string `json:"user_id"`
Error string `json:"error"`
ErrorDesc string `json:"error_description"`
}
if err := json.Unmarshal(bodyBytes, &result); err != nil {
return "", "", 0, fmt.Errorf("failed to decode response: %w", err)
}
if result.Error != "" {
return "", "", 0, fmt.Errorf("token exchange error: %s - %s", result.Error, result.ErrorDesc)
}
return result.AccessToken, result.RefreshToken, result.ExpiresIn, nil
}
// getFitbitUserInfo получает информацию о пользователе Fitbit
func getFitbitUserInfo(accessToken string) (string, error) {
req, err := http.NewRequest("GET", "https://api.fitbit.com/1/user/-/profile.json", nil)
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("Accept", "application/json")
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("failed to get user info: %w", err)
}
defer resp.Body.Close()
bodyBytes, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("get user info failed (status %d): %s", resp.StatusCode, string(bodyBytes))
}
var result struct {
User struct {
EncodedID string `json:"encodedId"`
} `json:"user"`
}
if err := json.Unmarshal(bodyBytes, &result); err != nil {
return "", fmt.Errorf("failed to decode response: %w", err)
}
if result.User.EncodedID == "" {
return "", fmt.Errorf("user ID not found in response")
}
return result.User.EncodedID, nil
}
// refreshFitbitToken обновляет access_token используя refresh_token
func refreshFitbitToken(refreshToken, clientID, clientSecret string) (accessToken, newRefreshToken string, expiresIn int, err error) {
data := url.Values{}
data.Set("grant_type", "refresh_token")
data.Set("refresh_token", refreshToken)
req, err := http.NewRequest("POST", "https://api.fitbit.com/oauth2/token", strings.NewReader(data.Encode()))
if err != nil {
return "", "", 0, fmt.Errorf("failed to create request: %w", err)
}
auth := base64.StdEncoding.EncodeToString([]byte(clientID + ":" + clientSecret))
req.Header.Set("Authorization", "Basic "+auth)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return "", "", 0, fmt.Errorf("failed to refresh token: %w", err)
}
defer resp.Body.Close()
bodyBytes, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return "", "", 0, fmt.Errorf("token refresh failed (status %d): %s", resp.StatusCode, string(bodyBytes))
}
var result struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int `json:"expires_in"`
Error string `json:"error"`
ErrorDesc string `json:"error_description"`
}
if err := json.Unmarshal(bodyBytes, &result); err != nil {
return "", "", 0, fmt.Errorf("failed to decode response: %w", err)
}
if result.Error != "" {
return "", "", 0, fmt.Errorf("token refresh error: %s - %s", result.Error, result.ErrorDesc)
}
return result.AccessToken, result.RefreshToken, result.ExpiresIn, nil
}
// fitbitOAuthConnectHandler инициирует OAuth flow для Fitbit
func (a *App) fitbitOAuthConnectHandler(w http.ResponseWriter, r *http.Request) {
setCORSHeaders(w)
userID, ok := getUserIDFromContext(r)
if !ok {
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
return
}
clientID := getEnv("FITBIT_CLIENT_ID", "")
clientSecret := getEnv("FITBIT_CLIENT_SECRET", "")
baseURL := getEnv("WEBHOOK_BASE_URL", "")
if clientID == "" || clientSecret == "" {
sendErrorWithCORS(w, "FITBIT_CLIENT_ID and FITBIT_CLIENT_SECRET must be configured", http.StatusInternalServerError)
return
}
if baseURL == "" {
sendErrorWithCORS(w, "WEBHOOK_BASE_URL must be configured", http.StatusInternalServerError)
return
}
redirectURI := strings.TrimRight(baseURL, "/") + "/api/integrations/fitbit/oauth/callback"
state, err := generateFitbitOAuthState(userID, a.jwtSecret)
if err != nil {
log.Printf("Fitbit OAuth: failed to generate state: %v", err)
sendErrorWithCORS(w, "Failed to generate OAuth state", http.StatusInternalServerError)
return
}
// Fitbit OAuth URL с необходимыми scopes
authURL := fmt.Sprintf(
"https://www.fitbit.com/oauth2/authorize?response_type=code&client_id=%s&redirect_uri=%s&scope=activity%%20profile&state=%s",
url.QueryEscape(clientID),
url.QueryEscape(redirectURI),
url.QueryEscape(state),
)
log.Printf("Fitbit OAuth: returning auth URL for user_id=%d", userID)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"auth_url": authURL,
})
}
// fitbitOAuthCallbackHandler обрабатывает OAuth callback от Fitbit
func (a *App) fitbitOAuthCallbackHandler(w http.ResponseWriter, r *http.Request) {
frontendURL := getEnv("WEBHOOK_BASE_URL", "")
redirectSuccess := frontendURL + "/?integration=fitbit&status=connected"
redirectError := frontendURL + "/?integration=fitbit&status=error"
clientID := getEnv("FITBIT_CLIENT_ID", "")
clientSecret := getEnv("FITBIT_CLIENT_SECRET", "")
baseURL := getEnv("WEBHOOK_BASE_URL", "")
if clientID == "" || clientSecret == "" || baseURL == "" {
log.Printf("Fitbit OAuth: missing configuration")
http.Redirect(w, r, redirectError+"&message=config_error", http.StatusTemporaryRedirect)
return
}
redirectURI := strings.TrimRight(baseURL, "/") + "/api/integrations/fitbit/oauth/callback"
// Проверяем state
state := r.URL.Query().Get("state")
userID, err := validateFitbitOAuthState(state, a.jwtSecret)
if err != nil {
log.Printf("Fitbit OAuth: invalid state: %v", err)
http.Redirect(w, r, redirectError+"&message=invalid_state", http.StatusTemporaryRedirect)
return
}
// Получаем code
code := r.URL.Query().Get("code")
if code == "" {
log.Printf("Fitbit OAuth: no code in callback")
http.Redirect(w, r, redirectError+"&message=no_code", http.StatusTemporaryRedirect)
return
}
// Обмениваем code на токены
accessToken, refreshToken, expiresIn, err := exchangeFitbitCodeForToken(code, redirectURI, clientID, clientSecret)
if err != nil {
log.Printf("Fitbit OAuth: token exchange failed: %v", err)
http.Redirect(w, r, redirectError+"&message=token_exchange_failed", http.StatusTemporaryRedirect)
return
}
// Получаем информацию о пользователе
fitbitUserID, err := getFitbitUserInfo(accessToken)
if err != nil {
log.Printf("Fitbit OAuth: get user info failed: %v", err)
http.Redirect(w, r, redirectError+"&message=user_info_failed", http.StatusTemporaryRedirect)
return
}
log.Printf("Fitbit OAuth: user_id=%d connected fitbit_user_id=%s", userID, fitbitUserID)
// Вычисляем время истечения токена
tokenExpiresAt := time.Now().Add(time.Duration(expiresIn) * time.Second)
// Сохраняем в БД
_, err = a.DB.Exec(`
INSERT INTO fitbit_integrations (user_id, fitbit_user_id, access_token, refresh_token, token_expires_at)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (user_id) DO UPDATE SET
fitbit_user_id = $2,
access_token = $3,
refresh_token = $4,
token_expires_at = $5,
updated_at = CURRENT_TIMESTAMP
`, userID, fitbitUserID, accessToken, refreshToken, tokenExpiresAt)
if err != nil {
log.Printf("Fitbit OAuth: DB error: %v", err)
http.Redirect(w, r, redirectError+"&message=db_error", http.StatusTemporaryRedirect)
return
}
// Редирект на страницу интеграций
http.Redirect(w, r, redirectSuccess, http.StatusTemporaryRedirect)
}
// getFitbitStatusHandler возвращает статус подключения Fitbit
func (a *App) getFitbitStatusHandler(w http.ResponseWriter, r *http.Request) {
if r.Method == "OPTIONS" {
setCORSHeaders(w)
w.WriteHeader(http.StatusOK)
return
}
setCORSHeaders(w)
userID, ok := getUserIDFromContext(r)
if !ok {
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
return
}
var fitbitUserID sql.NullString
var goalStepsMin, goalStepsMax, goalFloorsMin, goalFloorsMax, goalAzmMin, goalAzmMax sql.NullInt64
err := a.DB.QueryRow(`
SELECT fitbit_user_id, goal_steps_min, goal_steps_max, goal_floors_min, goal_floors_max, goal_azm_min, goal_azm_max
FROM fitbit_integrations
WHERE user_id = $1 AND access_token IS NOT NULL
`, userID).Scan(&fitbitUserID, &goalStepsMin, &goalStepsMax, &goalFloorsMin, &goalFloorsMax, &goalAzmMin, &goalAzmMax)
if err == sql.ErrNoRows || !fitbitUserID.Valid {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"connected": false,
})
return
}
if err != nil {
sendErrorWithCORS(w, fmt.Sprintf("Failed to get status: %v", err), http.StatusInternalServerError)
return
}
response := map[string]interface{}{
"connected": true,
"goals": map[string]interface{}{
"steps": map[string]interface{}{
"min": goalStepsMin.Int64,
"max": goalStepsMax.Int64,
},
"floors": map[string]interface{}{
"min": goalFloorsMin.Int64,
"max": goalFloorsMax.Int64,
},
"azm": map[string]interface{}{
"min": goalAzmMin.Int64,
"max": goalAzmMax.Int64,
},
},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// fitbitDisconnectHandler отключает интеграцию Fitbit
func (a *App) fitbitDisconnectHandler(w http.ResponseWriter, r *http.Request) {
if r.Method == "OPTIONS" {
setCORSHeaders(w)
w.WriteHeader(http.StatusOK)
return
}
setCORSHeaders(w)
userID, ok := getUserIDFromContext(r)
if !ok {
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
return
}
_, err := a.DB.Exec(`
DELETE FROM fitbit_integrations WHERE user_id = $1
`, userID)
if err != nil {
log.Printf("Fitbit disconnect: DB error: %v", err)
sendErrorWithCORS(w, fmt.Sprintf("Failed to disconnect: %v", err), http.StatusInternalServerError)
return
}
log.Printf("Fitbit disconnected for user_id=%d", userID)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"success": true,
"message": "Fitbit disconnected",
})
}
// updateFitbitGoalsHandler обновляет цели пользователя
func (a *App) updateFitbitGoalsHandler(w http.ResponseWriter, r *http.Request) {
if r.Method == "OPTIONS" {
setCORSHeaders(w)
w.WriteHeader(http.StatusOK)
return
}
setCORSHeaders(w)
userID, ok := getUserIDFromContext(r)
if !ok {
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
return
}
var req struct {
Steps map[string]int64 `json:"steps"`
Floors map[string]int64 `json:"floors"`
Azm map[string]int64 `json:"azm"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest)
return
}
_, err := a.DB.Exec(`
UPDATE fitbit_integrations
SET goal_steps_min = $1, goal_steps_max = $2,
goal_floors_min = $3, goal_floors_max = $4,
goal_azm_min = $5, goal_azm_max = $6,
updated_at = CURRENT_TIMESTAMP
WHERE user_id = $7
`, req.Steps["min"], req.Steps["max"],
req.Floors["min"], req.Floors["max"],
req.Azm["min"], req.Azm["max"],
userID)
if err != nil {
log.Printf("Fitbit update goals: DB error: %v", err)
sendErrorWithCORS(w, fmt.Sprintf("Failed to update goals: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"success": true,
"message": "Goals updated",
})
}
// getFitbitAccessToken получает актуальный access_token (обновляет если нужно)
func (a *App) getFitbitAccessToken(userID int) (string, error) {
var accessToken, refreshToken sql.NullString
var tokenExpiresAt sql.NullTime
err := a.DB.QueryRow(`
SELECT access_token, refresh_token, token_expires_at
FROM fitbit_integrations
WHERE user_id = $1
`, userID).Scan(&accessToken, &refreshToken, &tokenExpiresAt)
if err == sql.ErrNoRows {
return "", fmt.Errorf("fitbit integration not found")
}
if err != nil {
return "", fmt.Errorf("failed to get tokens: %w", err)
}
if !accessToken.Valid {
return "", fmt.Errorf("access token not found")
}
// Проверяем, не истек ли токен (с запасом 5 минут)
if tokenExpiresAt.Valid && time.Now().Add(5*time.Minute).After(tokenExpiresAt.Time) {
// Токен истек или скоро истечет, обновляем
if !refreshToken.Valid {
return "", fmt.Errorf("refresh token not found")
}
clientID := getEnv("FITBIT_CLIENT_ID", "")
clientSecret := getEnv("FITBIT_CLIENT_SECRET", "")
if clientID == "" || clientSecret == "" {
return "", fmt.Errorf("FITBIT_CLIENT_ID and FITBIT_CLIENT_SECRET must be configured")
}
newAccessToken, newRefreshToken, expiresIn, err := refreshFitbitToken(refreshToken.String, clientID, clientSecret)
if err != nil {
return "", fmt.Errorf("failed to refresh token: %w", err)
}
// Обновляем токены в БД
tokenExpiresAtNew := time.Now().Add(time.Duration(expiresIn) * time.Second)
_, err = a.DB.Exec(`
UPDATE fitbit_integrations
SET access_token = $1, refresh_token = $2, token_expires_at = $3, updated_at = CURRENT_TIMESTAMP
WHERE user_id = $4
`, newAccessToken, newRefreshToken, tokenExpiresAtNew, userID)
if err != nil {
return "", fmt.Errorf("failed to update tokens: %w", err)
}
log.Printf("Fitbit token refreshed for user_id=%d", userID)
return newAccessToken, nil
}
return accessToken.String, nil
}
// syncFitbitData синхронизирует данные из Fitbit API для указанной даты
func (a *App) syncFitbitData(userID int, date time.Time) error {
accessToken, err := a.getFitbitAccessToken(userID)
if err != nil {
return fmt.Errorf("failed to get access token: %w", err)
}
dateStr := date.Format("2006-01-02")
// Получаем данные активности за день
activityURL := fmt.Sprintf("https://api.fitbit.com/1/user/-/activities/date/%s.json", dateStr)
req, err := http.NewRequest("GET", activityURL, nil)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("Accept", "application/json")
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("failed to get activity data: %w", err)
}
defer resp.Body.Close()
bodyBytes, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("get activity data failed (status %d): %s", resp.StatusCode, string(bodyBytes))
}
var activityData struct {
Summary struct {
Steps int `json:"steps"`
Floors int `json:"floors"`
} `json:"summary"`
}
if err := json.Unmarshal(bodyBytes, &activityData); err != nil {
return fmt.Errorf("failed to decode activity data: %w", err)
}
// Получаем Active Zone Minutes
azmURL := fmt.Sprintf("https://api.fitbit.com/1/user/-/activities/active-zone-minutes/date/%s/1d.json", dateStr)
reqAZM, err := http.NewRequest("GET", azmURL, nil)
if err != nil {
return fmt.Errorf("failed to create AZM request: %w", err)
}
reqAZM.Header.Set("Authorization", "Bearer "+accessToken)
reqAZM.Header.Set("Accept", "application/json")
respAZM, err := client.Do(reqAZM)
if err != nil {
return fmt.Errorf("failed to get AZM data: %w", err)
}
defer respAZM.Body.Close()
bodyBytesAZM, _ := io.ReadAll(respAZM.Body)
var azmValue int
if respAZM.StatusCode == http.StatusOK {
var azmData struct {
ActivitiesActiveZoneMinutes []struct {
Value struct {
ActiveZoneMinutes int `json:"activeZoneMinutes"`
} `json:"value"`
} `json:"activities-active-zone-minutes"`
}
if err := json.Unmarshal(bodyBytesAZM, &azmData); err == nil {
if len(azmData.ActivitiesActiveZoneMinutes) > 0 {
azmValue = azmData.ActivitiesActiveZoneMinutes[0].Value.ActiveZoneMinutes
}
}
}
// Сохраняем данные в БД
_, err = a.DB.Exec(`
INSERT INTO fitbit_daily_stats (user_id, date, steps, floors, active_zone_minutes, updated_at)
VALUES ($1, $2, $3, $4, $5, CURRENT_TIMESTAMP)
ON CONFLICT (user_id, date) DO UPDATE SET
steps = $3,
floors = $4,
active_zone_minutes = $5,
updated_at = CURRENT_TIMESTAMP
`, userID, dateStr, activityData.Summary.Steps, activityData.Summary.Floors, azmValue)
if err != nil {
return fmt.Errorf("failed to save stats: %w", err)
}
log.Printf("Fitbit data synced for user_id=%d, date=%s: steps=%d, floors=%d, azm=%d",
userID, dateStr, activityData.Summary.Steps, activityData.Summary.Floors, azmValue)
return nil
}
// fitbitSyncHandler выполняет ручную синхронизацию данных Fitbit
func (a *App) fitbitSyncHandler(w http.ResponseWriter, r *http.Request) {
if r.Method == "OPTIONS" {
setCORSHeaders(w)
w.WriteHeader(http.StatusOK)
return
}
setCORSHeaders(w)
userID, ok := getUserIDFromContext(r)
if !ok {
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
return
}
// Синхронизируем данные за сегодня
err := a.syncFitbitData(userID, time.Now())
if err != nil {
log.Printf("Fitbit sync error: %v", err)
sendErrorWithCORS(w, fmt.Sprintf("Sync failed: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"success": true,
"message": "Data synced successfully",
})
}
// getFitbitStatsHandler возвращает статистику Fitbit за указанную дату
func (a *App) getFitbitStatsHandler(w http.ResponseWriter, r *http.Request) {
if r.Method == "OPTIONS" {
setCORSHeaders(w)
w.WriteHeader(http.StatusOK)
return
}
setCORSHeaders(w)
userID, ok := getUserIDFromContext(r)
if !ok {
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
return
}
// Получаем дату из query параметра (по умолчанию сегодня)
dateStr := r.URL.Query().Get("date")
if dateStr == "" {
dateStr = time.Now().Format("2006-01-02")
}
var steps, floors, azm sql.NullInt64
err := a.DB.QueryRow(`
SELECT steps, floors, active_zone_minutes
FROM fitbit_daily_stats
WHERE user_id = $1 AND date = $2
`, userID, dateStr).Scan(&steps, &floors, &azm)
if err == sql.ErrNoRows {
// Данных нет, возвращаем нули
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"date": dateStr,
"steps": 0,
"floors": 0,
"azm": 0,
})
return
}
if err != nil {
sendErrorWithCORS(w, fmt.Sprintf("Failed to get stats: %v", err), http.StatusInternalServerError)
return
}
// Получаем цели пользователя
var goalStepsMin, goalStepsMax, goalFloorsMin, goalFloorsMax, goalAzmMin, goalAzmMax sql.NullInt64
err = a.DB.QueryRow(`
SELECT goal_steps_min, goal_steps_max, goal_floors_min, goal_floors_max, goal_azm_min, goal_azm_max
FROM fitbit_integrations
WHERE user_id = $1
`, userID).Scan(&goalStepsMin, &goalStepsMax, &goalFloorsMin, &goalFloorsMax, &goalAzmMin, &goalAzmMax)
if err != nil {
// Если целей нет, используем значения по умолчанию
goalStepsMin = sql.NullInt64{Int64: 8000, Valid: true}
goalStepsMax = sql.NullInt64{Int64: 10000, Valid: true}
goalFloorsMin = sql.NullInt64{Int64: 8, Valid: true}
goalFloorsMax = sql.NullInt64{Int64: 10, Valid: true}
goalAzmMin = sql.NullInt64{Int64: 22, Valid: true}
goalAzmMax = sql.NullInt64{Int64: 44, Valid: true}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"date": dateStr,
"steps": map[string]interface{}{
"value": steps.Int64,
"goal": map[string]interface{}{
"min": goalStepsMin.Int64,
"max": goalStepsMax.Int64,
},
},
"floors": map[string]interface{}{
"value": floors.Int64,
"goal": map[string]interface{}{
"min": goalFloorsMin.Int64,
"max": goalFloorsMax.Int64,
},
},
"azm": map[string]interface{}{
"value": azm.Int64,
"goal": map[string]interface{}{
"min": goalAzmMin.Int64,
"max": goalAzmMax.Int64,
},
},
})
}
// ============================================ // ============================================
// Wishlist handlers // Wishlist handlers
// ============================================ // ============================================

View File

@@ -0,0 +1,2 @@
DROP TABLE IF EXISTS fitbit_daily_stats;
DROP TABLE IF EXISTS fitbit_integrations;

View File

@@ -0,0 +1,38 @@
-- Fitbit integrations table (depends on users)
CREATE TABLE fitbit_integrations (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
fitbit_user_id VARCHAR(255),
access_token TEXT,
refresh_token TEXT,
token_expires_at TIMESTAMP WITH TIME ZONE,
goal_steps_min INTEGER DEFAULT 8000,
goal_steps_max INTEGER DEFAULT 10000,
goal_floors_min INTEGER DEFAULT 8,
goal_floors_max INTEGER DEFAULT 10,
goal_azm_min INTEGER DEFAULT 22,
goal_azm_max INTEGER DEFAULT 44,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fitbit_integrations_user_id_unique UNIQUE (user_id)
);
CREATE INDEX idx_fitbit_integrations_user_id ON fitbit_integrations(user_id);
CREATE UNIQUE INDEX idx_fitbit_integrations_fitbit_user_id ON fitbit_integrations(fitbit_user_id) WHERE fitbit_user_id IS NOT NULL;
-- Fitbit daily stats table (depends on users and fitbit_integrations)
CREATE TABLE fitbit_daily_stats (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
date DATE NOT NULL,
steps INTEGER DEFAULT 0,
floors INTEGER DEFAULT 0,
active_zone_minutes INTEGER DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fitbit_daily_stats_user_date_unique UNIQUE (user_id, date)
);
CREATE INDEX idx_fitbit_daily_stats_user_id ON fitbit_daily_stats(user_id);
CREATE INDEX idx_fitbit_daily_stats_date ON fitbit_daily_stats(date);
CREATE INDEX idx_fitbit_daily_stats_user_date ON fitbit_daily_stats(user_id, date);

View File

@@ -86,6 +86,17 @@ server {
add_header Cache-Control "public, immutable"; add_header Cache-Control "public, immutable";
} }
# Статические HTML страницы (Terms и Privacy)
location = /terms {
try_files /terms.html =404;
add_header Cache-Control "public, max-age=3600";
}
location = /privacy {
try_files /privacy.html =404;
add_header Cache-Control "public, max-age=3600";
}
# Handle React Router (SPA) # Handle React Router (SPA)
location / { location / {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;

View File

@@ -1,6 +1,6 @@
{ {
"name": "play-life-web", "name": "play-life-web",
"version": "4.26.0", "version": "4.26.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

View File

@@ -0,0 +1,159 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Политика конфиденциальности - Play Life</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
line-height: 1.6;
color: #333;
background: #f5f5f5;
padding: 20px;
}
.container {
max-width: 800px;
margin: 0 auto;
background: white;
padding: 40px;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
h1 {
color: #4f46e5;
margin-bottom: 30px;
font-size: 2em;
}
h2 {
color: #1f2937;
margin-top: 30px;
margin-bottom: 15px;
font-size: 1.5em;
}
p {
margin-bottom: 15px;
text-align: justify;
}
ul {
margin-left: 20px;
margin-bottom: 15px;
}
li {
margin-bottom: 8px;
}
.last-updated {
color: #6b7280;
font-size: 0.9em;
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #e5e7eb;
}
</style>
</head>
<body>
<div class="container">
<h1>Политика конфиденциальности</h1>
<p><strong>Дата вступления в силу:</strong> 1 января 2024 года</p>
<h2>1. Введение</h2>
<p>Play Life ("мы", "наш", "нас") уважает вашу конфиденциальность и обязуется защищать ваши личные данные. Настоящая Политика конфиденциальности объясняет, как мы собираем, используем, храним и защищаем вашу информацию при использовании нашего приложения.</p>
<h2>2. Собираемая информация</h2>
<p>Мы собираем следующие типы информации:</p>
<h3>2.1. Информация, предоставляемая вами</h3>
<ul>
<li>Имя и адрес электронной почты при регистрации</li>
<li>Данные о ваших проектах, задачах и целях</li>
<li>Списки желаний и связанная информация</li>
<li>Словари и слова для изучения</li>
</ul>
<h3>2.2. Информация из интеграций</h3>
<ul>
<li><strong>Todoist:</strong> Информация о ваших задачах (только при подключении интеграции)</li>
<li><strong>Telegram:</strong> ID пользователя Telegram (только при подключении бота)</li>
<li><strong>Fitbit:</strong> Данные о физической активности, включая шаги, этажи и активные зоны минут (только при подключении интеграции)</li>
</ul>
<h3>2.3. Автоматически собираемая информация</h3>
<ul>
<li>Данные об использовании приложения (логи доступа, ошибки)</li>
<li>Техническая информация (версия браузера, тип устройства)</li>
</ul>
<h2>3. Использование информации</h2>
<p>Мы используем собранную информацию для:</p>
<ul>
<li>Предоставления и улучшения функциональности приложения</li>
<li>Обработки ваших запросов и транзакций</li>
<li>Отправки уведомлений и обновлений (если вы подписаны)</li>
<li>Обеспечения безопасности и предотвращения мошенничества</li>
<li>Соблюдения юридических обязательств</li>
</ul>
<h2>4. Хранение данных</h2>
<p>Ваши данные хранятся на защищенных серверах. Мы применяем соответствующие технические и организационные меры для защиты ваших данных от несанкционированного доступа, изменения, раскрытия или уничтожения.</p>
<h2>5. Обмен данными</h2>
<p>Мы не продаем и не передаем ваши личные данные третьим лицам, за исключением:</p>
<ul>
<li>Когда это необходимо для предоставления услуг (например, интеграции с Fitbit, Todoist, Telegram)</li>
<li>Когда это требуется по закону или по запросу государственных органов</li>
<li>С вашего явного согласия</li>
</ul>
<h2>6. Интеграции с третьими сторонами</h2>
<p>При использовании интеграций с Fitbit, Todoist или Telegram, ваши данные могут передаваться этим сервисам в соответствии с их политиками конфиденциальности:</p>
<ul>
<li><strong>Fitbit:</strong> Мы получаем доступ только к данным о физической активности (шаги, этажи, активные зоны минут) с вашего явного разрешения через OAuth.</li>
<li><strong>Todoist:</strong> Мы получаем доступ только к информации о завершенных задачах для синхронизации с вашими проектами.</li>
<li><strong>Telegram:</strong> Мы получаем только ваш Telegram ID для связи с ботом.</li>
</ul>
<h2>7. Ваши права</h2>
<p>Вы имеете право:</p>
<ul>
<li>Получить доступ к вашим личным данным</li>
<li>Исправить неточные данные</li>
<li>Удалить ваши данные</li>
<li>Отозвать согласие на обработку данных</li>
<li>Ограничить обработку ваших данных</li>
<li>Получить копию ваших данных в структурированном формате</li>
</ul>
<p>Для осуществления этих прав свяжитесь с нами через приложение.</p>
<h2>8. Cookies и аналогичные технологии</h2>
<p>Мы используем cookies и аналогичные технологии для улучшения работы приложения, анализа использования и персонализации контента. Вы можете управлять настройками cookies в вашем браузере.</p>
<h2>9. Безопасность</h2>
<p>Мы применяем различные меры безопасности для защиты ваших данных, включая шифрование, контроль доступа и регулярные проверки безопасности. Однако ни один метод передачи через Интернет или электронного хранения не является на 100% безопасным.</p>
<h2>10. Хранение данных</h2>
<p>Мы храним ваши данные до тех пор, пока это необходимо для предоставления услуг или до тех пор, пока вы не попросите нас удалить их. Некоторые данные могут храниться дольше в соответствии с требованиями законодательства.</p>
<h2>11. Дети</h2>
<p>Наше приложение не предназначено для лиц младше 13 лет. Мы сознательно не собираем личную информацию от детей младше 13 лет.</p>
<h2>12. Изменения в политике</h2>
<p>Мы можем периодически обновлять настоящую Политику конфиденциальности. Мы уведомим вас о любых существенных изменениях, разместив новую политику на этой странице и обновив дату "Последнее обновление".</p>
<h2>13. Контактная информация</h2>
<p>Если у вас есть вопросы или запросы относительно настоящей Политики конфиденциальности или обработки ваших данных, пожалуйста, свяжитесь с нами через приложение.</p>
<h2>14. Применимое законодательство</h2>
<p>Настоящая Политика конфиденциальности регулируется законодательством Российской Федерации, включая Федеральный закон "О персональных данных" № 152-ФЗ.</p>
<div class="last-updated">
<p><strong>Последнее обновление:</strong> 1 января 2024 года</p>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,128 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Условия использования - Play Life</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
line-height: 1.6;
color: #333;
background: #f5f5f5;
padding: 20px;
}
.container {
max-width: 800px;
margin: 0 auto;
background: white;
padding: 40px;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
h1 {
color: #4f46e5;
margin-bottom: 30px;
font-size: 2em;
}
h2 {
color: #1f2937;
margin-top: 30px;
margin-bottom: 15px;
font-size: 1.5em;
}
p {
margin-bottom: 15px;
text-align: justify;
}
ul {
margin-left: 20px;
margin-bottom: 15px;
}
li {
margin-bottom: 8px;
}
.last-updated {
color: #6b7280;
font-size: 0.9em;
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #e5e7eb;
}
</style>
</head>
<body>
<div class="container">
<h1>Условия использования</h1>
<p><strong>Дата вступления в силу:</strong> 1 января 2024 года</p>
<h2>1. Принятие условий</h2>
<p>Используя приложение Play Life, вы соглашаетесь с настоящими Условиями использования. Если вы не согласны с какими-либо условиями, пожалуйста, не используйте наше приложение.</p>
<h2>2. Описание сервиса</h2>
<p>Play Life — это приложение для отслеживания продуктивности и личных целей, которое позволяет пользователям:</p>
<ul>
<li>Отслеживать прогресс по проектам и задачам</li>
<li>Управлять списками желаний</li>
<li>Изучать слова и создавать словари</li>
<li>Интегрироваться с внешними сервисами (Todoist, Telegram, Fitbit)</li>
</ul>
<h2>3. Регистрация и учетные записи</h2>
<p>Для использования некоторых функций приложения требуется создание учетной записи. Вы обязуетесь:</p>
<ul>
<li>Предоставлять точную и актуальную информацию</li>
<li>Поддерживать безопасность вашей учетной записи</li>
<li>Нести ответственность за все действия, совершенные под вашей учетной записью</li>
<li>Немедленно уведомлять нас о любом несанкционированном использовании</li>
</ul>
<h2>4. Использование сервиса</h2>
<p>Вы соглашаетесь использовать Play Life только в законных целях и не будете:</p>
<ul>
<li>Нарушать какие-либо применимые законы или нормативные акты</li>
<li>Передавать вредоносное программное обеспечение или код</li>
<li>Пытаться получить несанкционированный доступ к сервису</li>
<li>Использовать сервис для спама или рассылки нежелательных сообщений</li>
<li>Нарушать права интеллектуальной собственности других лиц</li>
</ul>
<h2>5. Интеграции с третьими сторонами</h2>
<p>Play Life может интегрироваться с внешними сервисами (Todoist, Telegram, Fitbit). Использование этих интеграций регулируется условиями использования соответствующих сервисов. Мы не несем ответственности за действия или политики этих третьих сторон.</p>
<h2>6. Интеллектуальная собственность</h2>
<p>Все материалы, содержащиеся в Play Life, включая, но не ограничиваясь текстом, графикой, логотипами, иконками, изображениями, являются собственностью Play Life или их соответствующих владельцев и защищены законами об авторском праве.</p>
<h2>7. Конфиденциальность</h2>
<p>Использование ваших личных данных регулируется нашей <a href="/privacy.html">Политикой конфиденциальности</a>. Используя Play Life, вы соглашаетесь с обработкой ваших данных в соответствии с этой политикой.</p>
<h2>8. Отказ от ответственности</h2>
<p>Play Life предоставляется "как есть" без каких-либо гарантий, явных или подразумеваемых. Мы не гарантируем, что сервис будет бесперебойным, безопасным или безошибочным.</p>
<h2>9. Ограничение ответственности</h2>
<p>В максимальной степени, разрешенной законом, Play Life не несет ответственности за любые прямые, косвенные, случайные, особые или последующие убытки, возникающие в результате использования или невозможности использования сервиса.</p>
<h2>10. Изменения в условиях</h2>
<p>Мы оставляем за собой право изменять настоящие Условия использования в любое время. Изменения вступают в силу с момента их публикации. Продолжение использования сервиса после внесения изменений означает ваше согласие с новыми условиями.</p>
<h2>11. Прекращение использования</h2>
<p>Мы можем приостановить или прекратить ваш доступ к сервису в любое время, с уведомлением или без него, по любой причине, включая нарушение настоящих Условий использования.</p>
<h2>12. Применимое право</h2>
<p>Настоящие Условия использования регулируются и толкуются в соответствии с законодательством Российской Федерации.</p>
<h2>13. Контактная информация</h2>
<p>Если у вас есть вопросы относительно настоящих Условий использования, пожалуйста, свяжитесь с нами через приложение.</p>
<div class="last-updated">
<p><strong>Последнее обновление:</strong> 1 января 2024 года</p>
</div>
</div>
</body>
</html>

View File

@@ -16,6 +16,7 @@ import BoardForm from './components/BoardForm'
import BoardJoinPreview from './components/BoardJoinPreview' import BoardJoinPreview from './components/BoardJoinPreview'
import TodoistIntegration from './components/TodoistIntegration' import TodoistIntegration from './components/TodoistIntegration'
import TelegramIntegration from './components/TelegramIntegration' import TelegramIntegration from './components/TelegramIntegration'
import FitbitIntegration from './components/FitbitIntegration'
import Tracking from './components/Tracking' import Tracking from './components/Tracking'
import TrackingAccess from './components/TrackingAccess' import TrackingAccess from './components/TrackingAccess'
import TrackingInviteAccept from './components/TrackingInviteAccept' import TrackingInviteAccept from './components/TrackingInviteAccept'
@@ -29,24 +30,13 @@ const FULL_STATISTICS_API_URL = '/d2dc349a-0d13-49b2-a8f0-1ab094bfba9b'
// Определяем основные табы (без крестика) и глубокие табы (с крестиком) // Определяем основные табы (без крестика) и глубокие табы (с крестиком)
const mainTabs = ['current', 'tasks', 'wishlist', 'profile'] const mainTabs = ['current', 'tasks', 'wishlist', 'profile']
const deepTabs = ['add-words', 'test', 'task-form', 'wishlist-form', 'wishlist-detail', 'board-form', 'board-join', 'words', 'dictionaries', 'todoist-integration', 'telegram-integration', 'full', 'priorities', 'tracking', 'tracking-access', 'tracking-invite'] const deepTabs = ['add-words', 'test', 'task-form', 'wishlist-form', 'wishlist-detail', 'board-form', 'board-join', 'words', 'dictionaries', 'todoist-integration', 'telegram-integration', 'fitbit-integration', 'full', 'priorities', 'tracking', 'tracking-access', 'tracking-invite']
function AppContent() { function AppContent() {
const { authFetch, isAuthenticated, loading: authLoading } = useAuth() const { authFetch, isAuthenticated, loading: authLoading } = useAuth()
const prevIsAuthenticatedRef = useRef(null)
// 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) {
return <AuthScreen />
}
const [activeTab, setActiveTab] = useState('current') const [activeTab, setActiveTab] = useState('current')
const [selectedProject, setSelectedProject] = useState(null) const [selectedProject, setSelectedProject] = useState(null)
const [loadedTabs, setLoadedTabs] = useState({ const [loadedTabs, setLoadedTabs] = useState({
@@ -67,6 +57,7 @@ function AppContent() {
profile: false, profile: false,
'todoist-integration': false, 'todoist-integration': false,
'telegram-integration': false, 'telegram-integration': false,
'fitbit-integration': false,
tracking: false, tracking: false,
'tracking-access': false, 'tracking-access': false,
'tracking-invite': false, 'tracking-invite': false,
@@ -91,6 +82,7 @@ function AppContent() {
profile: false, profile: false,
'todoist-integration': false, 'todoist-integration': false,
'telegram-integration': false, 'telegram-integration': false,
'fitbit-integration': false,
tracking: false, tracking: false,
'tracking-access': false, 'tracking-access': false,
'tracking-invite': false, 'tracking-invite': false,
@@ -147,6 +139,36 @@ function AppContent() {
// Восстанавливаем последний выбранный таб после перезагрузки // Восстанавливаем последний выбранный таб после перезагрузки
const [isInitialized, setIsInitialized] = useState(false) const [isInitialized, setIsInitialized] = useState(false)
// Переключение на экран прогрессии после успешной авторизации
useEffect(() => {
// Обновляем ref только после того, как authLoading стал false
if (!authLoading) {
const wasNotAuthenticated = prevIsAuthenticatedRef.current === false
prevIsAuthenticatedRef.current = isAuthenticated
// Проверяем, что это новая авторизация (переход с false на true)
// и что инициализация уже завершена (чтобы не конфликтовать с восстановлением из URL/localStorage)
if (wasNotAuthenticated && isAuthenticated && isInitialized) {
// Переключаемся на экран прогресса только если нет таба в 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 // Инициализация из URL (только для глубоких табов) или localStorage
useEffect(() => { useEffect(() => {
if (isInitialized) return if (isInitialized) return
@@ -183,7 +205,7 @@ function AppContent() {
// Проверяем URL только для глубоких табов // Проверяем URL только для глубоких табов
const urlParams = new URLSearchParams(window.location.search) const urlParams = new URLSearchParams(window.location.search)
const tabFromUrl = urlParams.get('tab') const tabFromUrl = urlParams.get('tab')
const validTabs = ['current', 'priorities', 'full', 'words', 'add-words', 'dictionaries', 'test', 'tasks', 'task-form', 'wishlist', 'wishlist-form', 'wishlist-detail', 'board-form', 'board-join', 'profile', 'todoist-integration', 'telegram-integration', 'tracking', 'tracking-access', 'tracking-invite'] const validTabs = ['current', 'priorities', 'full', 'words', 'add-words', 'dictionaries', 'test', 'tasks', 'task-form', 'wishlist', 'wishlist-form', 'wishlist-detail', 'board-form', 'board-join', 'profile', 'todoist-integration', 'telegram-integration', 'fitbit-integration', 'tracking', 'tracking-access', 'tracking-invite']
if (tabFromUrl && validTabs.includes(tabFromUrl) && deepTabs.includes(tabFromUrl)) { if (tabFromUrl && validTabs.includes(tabFromUrl) && deepTabs.includes(tabFromUrl)) {
// Если в URL есть глубокий таб, восстанавливаем его // Если в URL есть глубокий таб, восстанавливаем его
@@ -494,6 +516,7 @@ function AppContent() {
profile: false, profile: false,
'todoist-integration': false, 'todoist-integration': false,
'telegram-integration': false, 'telegram-integration': false,
'fitbit-integration': false,
tracking: false, tracking: false,
'tracking-access': false, 'tracking-access': false,
'tracking-invite': false, 'tracking-invite': false,
@@ -507,6 +530,10 @@ function AppContent() {
todayEntries: null, todayEntries: null,
}) })
// Refs для отслеживания активного таба
const prevActiveTabRef = useRef(null)
const lastLoadedTabRef = useRef(null) // Отслеживаем последний загруженный таб, чтобы избежать двойной загрузки
// Обновляем ref при изменении данных // Обновляем ref при изменении данных
useEffect(() => { useEffect(() => {
cacheRef.current.current = currentWeekData cacheRef.current.current = currentWeekData
@@ -886,9 +913,6 @@ function AppContent() {
} }
// Загружаем данные при открытии таба (когда таб становится активным) // Загружаем данные при открытии таба (когда таб становится активным)
const prevActiveTabRef = useRef(null)
const lastLoadedTabRef = useRef(null) // Отслеживаем последний загруженный таб, чтобы избежать двойной загрузки
useEffect(() => { useEffect(() => {
if (!activeTab || !loadedTabs[activeTab]) return if (!activeTab || !loadedTabs[activeTab]) return
@@ -946,8 +970,23 @@ function AppContent() {
} }
}, [activeTab]) }, [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 экранов) // Определяем, нужно ли скрывать нижнюю панель (для fullscreen экранов)
const isFullscreenTab = activeTab === 'test' || activeTab === 'add-words' || activeTab === 'task-form' || activeTab === 'wishlist-form' || activeTab === 'wishlist-detail' || activeTab === 'todoist-integration' || activeTab === 'telegram-integration' || activeTab === 'full' || activeTab === 'priorities' || activeTab === 'words' || activeTab === 'dictionaries' || activeTab === 'tracking' || activeTab === 'tracking-access' || activeTab === 'tracking-invite' const isFullscreenTab = activeTab === 'test' || 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'
// Функция для получения классов скролл-контейнера для каждого таба // Функция для получения классов скролл-контейнера для каждого таба
// Каждый таб имеет свой изолированный скролл-контейнер для автоматического сохранения позиции скролла // Каждый таб имеет свой изолированный скролл-контейнер для автоматического сохранения позиции скролла
@@ -1209,6 +1248,14 @@ function AppContent() {
</div> </div>
)} )}
{loadedTabs['fitbit-integration'] && (
<div className={getTabContainerClasses('fitbit-integration')}>
<div className={getInnerContainerClasses('fitbit-integration')}>
<FitbitIntegration onNavigate={handleNavigate} />
</div>
</div>
)}
{loadedTabs.tracking && ( {loadedTabs.tracking && (
<div className={getTabContainerClasses('tracking')}> <div className={getTabContainerClasses('tracking')}>
<div className={getInnerContainerClasses('tracking')}> <div className={getInnerContainerClasses('tracking')}>

View File

@@ -0,0 +1,480 @@
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 [toastMessage, setToastMessage] = useState(null)
const [isLoadingError, setIsLoadingError] = useState(false)
const [goals, setGoals] = useState({
steps: { min: 8000, max: 10000 },
floors: { min: 8, max: 10 },
azm: { min: 22, max: 44 }
})
const [stats, setStats] = useState({
steps: { value: 0, goal: { min: 8000, max: 10000 } },
floors: { value: 0, goal: { min: 8, max: 10 } },
azm: { value: 0, goal: { min: 22, max: 44 } }
})
const [isEditingGoals, setIsEditingGoals] = useState(false)
const [editedGoals, setEditedGoals] = useState(goals)
const [syncing, setSyncing] = useState(false)
useEffect(() => {
checkStatus()
// Проверяем URL параметры для сообщений
const params = new URLSearchParams(window.location.search)
const integration = params.get('integration')
const status = params.get('status')
if (integration === 'fitbit') {
if (status === 'connected') {
setMessage('✅ Fitbit успешно подключен!')
// Очищаем URL параметры
window.history.replaceState({}, '', window.location.pathname)
} else if (status === 'error') {
const errorMsg = params.get('message') || 'Произошла ошибка'
setToastMessage({ text: errorMsg, type: 'error' })
window.history.replaceState({}, '', window.location.pathname)
}
}
}, [])
useEffect(() => {
if (connected) {
loadStats()
}
}, [connected])
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.goals) {
setGoals(data.goals)
setEditedGoals(data.goals)
}
} 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(data)
// Обновляем цели из ответа
if (data.steps?.goal) {
setGoals({
steps: data.steps.goal,
floors: data.floors.goal,
azm: data.azm.goal
})
setEditedGoals({
steps: data.steps.goal,
floors: data.floors.goal,
azm: data.azm.goal
})
}
} catch (error) {
console.error('Error loading stats:', error)
// Не показываем ошибку, просто не обновляем статистику
}
}
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: { min: 8000, max: 10000 } },
floors: { value: 0, goal: { min: 8, max: 10 } },
azm: { value: 0, goal: { min: 22, max: 44 } }
})
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 handleSaveGoals = async () => {
try {
const response = await authFetch('/api/integrations/fitbit/goals', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
steps: editedGoals.steps,
floors: editedGoals.floors,
azm: editedGoals.azm,
}),
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw new Error(errorData.error || 'Ошибка при сохранении целей')
}
setGoals(editedGoals)
setIsEditingGoals(false)
setToastMessage({ text: 'Цели сохранены', type: 'success' })
await loadStats()
} catch (error) {
console.error('Error saving goals:', error)
setToastMessage({ text: error.message || 'Не удалось сохранить цели', type: 'error' })
}
}
const handleCancelEdit = () => {
setEditedGoals(goals)
setIsEditingGoals(false)
}
const getProgressPercent = (value, min, max) => {
if (value >= max) return 100
if (value <= min) return (value / min) * 50
return 50 + ((value - min) / (max - min)) * 50
}
const getProgressColor = (value, min, max) => {
if (value >= max) return 'text-green-600'
if (value >= min) return 'text-blue-600'
return 'text-gray-600'
}
if (isLoadingError && !loading) {
return (
<div className="p-4 md:p-6">
<button className="close-x-button" onClick={() => onNavigate?.('profile')} title="Закрыть">
</button>
<LoadingError onRetry={checkStatus} />
</div>
)
}
return (
<div className="p-4 md:p-6">
<button className="close-x-button" onClick={() => onNavigate?.('profile')} title="Закрыть">
</button>
<h1 className="text-2xl font-bold mb-6">Fitbit интеграция</h1>
{loading ? (
<div className="fixed inset-0 flex justify-center items-center">
<div className="flex flex-col items-center">
<div className="w-12 h-12 border-4 border-indigo-200 border-t-indigo-600 rounded-full animate-spin mb-4"></div>
<div className="text-gray-600 font-medium">Загрузка...</div>
</div>
</div>
) : connected ? (
<div>
{message && (
<div className="bg-green-50 border border-green-200 rounded-lg p-4 mb-6">
<p className="text-green-800">{message}</p>
</div>
)}
{/* Статистика */}
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-lg font-semibold">Статистика за сегодня</h2>
<button
onClick={handleSync}
disabled={syncing}
className="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed text-sm"
>
{syncing ? 'Синхронизация...' : 'Синхронизировать'}
</button>
</div>
{/* Шаги */}
<div className="mb-6">
<div className="flex justify-between items-center mb-2">
<span className="text-gray-700 font-medium">Шаги</span>
<span className={`font-bold ${getProgressColor(stats.steps.value, stats.steps.goal.min, stats.steps.goal.max)}`}>
{stats.steps.value.toLocaleString()} / {stats.steps.goal.min}-{stats.steps.goal.max}
</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: `${Math.min(100, getProgressPercent(stats.steps.value, stats.steps.goal.min, stats.steps.goal.max))}%` }}
></div>
</div>
</div>
{/* Этажи */}
<div className="mb-6">
<div className="flex justify-between items-center mb-2">
<span className="text-gray-700 font-medium">Этажи</span>
<span className={`font-bold ${getProgressColor(stats.floors.value, stats.floors.goal.min, stats.floors.goal.max)}`}>
{stats.floors.value} / {stats.floors.goal.min}-{stats.floors.goal.max}
</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: `${Math.min(100, getProgressPercent(stats.floors.value, stats.floors.goal.min, stats.floors.goal.max))}%` }}
></div>
</div>
</div>
{/* Баллы кардио (AZM) */}
<div className="mb-6">
<div className="flex justify-between items-center mb-2">
<span className="text-gray-700 font-medium">Баллы кардио</span>
<span className={`font-bold ${getProgressColor(stats.azm.value, stats.azm.goal.min, stats.azm.goal.max)}`}>
{stats.azm.value} / {stats.azm.goal.min}-{stats.azm.goal.max}
</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: `${Math.min(100, getProgressPercent(stats.azm.value, stats.azm.goal.min, stats.azm.goal.max))}%` }}
></div>
</div>
</div>
</div>
{/* Настройка целей */}
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-lg font-semibold">Дневные цели</h2>
{!isEditingGoals && (
<button
onClick={() => setIsEditingGoals(true)}
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors text-sm"
>
Изменить
</button>
)}
</div>
{isEditingGoals ? (
<div className="space-y-4">
{/* Шаги */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Шаги (мин - макс)</label>
<div className="flex gap-2">
<input
type="number"
value={editedGoals.steps.min}
onChange={(e) => setEditedGoals({ ...editedGoals, steps: { ...editedGoals.steps, min: parseInt(e.target.value) || 0 } })}
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg"
/>
<input
type="number"
value={editedGoals.steps.max}
onChange={(e) => setEditedGoals({ ...editedGoals, steps: { ...editedGoals.steps, max: parseInt(e.target.value) || 0 } })}
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg"
/>
</div>
</div>
{/* Этажи */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Этажи (мин - макс)</label>
<div className="flex gap-2">
<input
type="number"
value={editedGoals.floors.min}
onChange={(e) => setEditedGoals({ ...editedGoals, floors: { ...editedGoals.floors, min: parseInt(e.target.value) || 0 } })}
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg"
/>
<input
type="number"
value={editedGoals.floors.max}
onChange={(e) => setEditedGoals({ ...editedGoals, floors: { ...editedGoals.floors, max: parseInt(e.target.value) || 0 } })}
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg"
/>
</div>
</div>
{/* Баллы кардио */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Баллы кардио (мин - макс)</label>
<div className="flex gap-2">
<input
type="number"
value={editedGoals.azm.min}
onChange={(e) => setEditedGoals({ ...editedGoals, azm: { ...editedGoals.azm, min: parseInt(e.target.value) || 0 } })}
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg"
/>
<input
type="number"
value={editedGoals.azm.max}
onChange={(e) => setEditedGoals({ ...editedGoals, azm: { ...editedGoals.azm, max: parseInt(e.target.value) || 0 } })}
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg"
/>
</div>
</div>
<div className="flex gap-2">
<button
onClick={handleSaveGoals}
className="flex-1 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors"
>
Сохранить
</button>
<button
onClick={handleCancelEdit}
className="flex-1 px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors"
>
Отмена
</button>
</div>
</div>
) : (
<div className="space-y-3">
<div className="flex justify-between">
<span className="text-gray-600">Шаги:</span>
<span className="font-medium">{goals.steps.min} - {goals.steps.max}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Этажи:</span>
<span className="font-medium">{goals.floors.min} - {goals.floors.max}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Баллы кардио:</span>
<span className="font-medium">{goals.azm.min} - {goals.azm.max}</span>
</div>
</div>
)}
</div>
<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>
<p className="text-gray-700 mb-2">
Fitbit подключен! Данные синхронизируются автоматически каждые 4 часа.
</p>
<p className="text-gray-600 text-sm">
Вы также можете синхронизировать данные вручную, нажав кнопку "Синхронизировать".
</p>
</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>
<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

View File

@@ -8,6 +8,7 @@ function Profile({ onNavigate }) {
const integrations = [ const integrations = [
{ id: 'todoist-integration', name: 'TODOist' }, { id: 'todoist-integration', name: 'TODOist' },
{ id: 'telegram-integration', name: 'Telegram' }, { id: 'telegram-integration', name: 'Telegram' },
{ id: 'fitbit-integration', name: 'Fitbit' },
] ]
const handleLogout = async () => { const handleLogout = async () => {