From dfccba4e55fed8be072d436f19ee6fcaa9b0ad99 Mon Sep 17 00:00:00 2001 From: poignatov Date: Fri, 6 Feb 2026 20:50:49 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B0=20=D0=B8=D0=BD=D1=82=D0=B5=D0=B3=D1=80=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D1=8F=20=D1=81=20Fitbit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .DS_Store | Bin 6148 -> 8196 bytes VERSION | 2 +- env.example | 20 + play-life-backend/main.go | 804 ++++++++++++++++++ .../000015_add_fitbit_integration.down.sql | 2 + .../000015_add_fitbit_integration.up.sql | 38 + play-life-web/nginx.conf | 11 + play-life-web/package.json | 2 +- play-life-web/public/privacy.html | 159 ++++ play-life-web/public/terms.html | 128 +++ play-life-web/src/App.jsx | 85 +- .../src/components/FitbitIntegration.jsx | 480 +++++++++++ play-life-web/src/components/Profile.jsx | 1 + 13 files changed, 1711 insertions(+), 21 deletions(-) create mode 100644 play-life-backend/migrations/000015_add_fitbit_integration.down.sql create mode 100644 play-life-backend/migrations/000015_add_fitbit_integration.up.sql create mode 100644 play-life-web/public/privacy.html create mode 100644 play-life-web/public/terms.html create mode 100644 play-life-web/src/components/FitbitIntegration.jsx diff --git a/.DS_Store b/.DS_Store index f91544d947a477ef74a973aebaa7d33eff8d1655..f5ce26c847968c26abf4dd63c72c722c9dc936d0 100644 GIT binary patch literal 8196 zcmeHM&1(}u6n~S(WYf~pf|V$O3l#)uYio;AdWkVT_<^7idr)b!+1Q3|c0x93kXl%z z2v)CRMeu`Og`y{WR`BS}b5A{q_#fz-kED}NBZ3s2ftk0Nd2inD{bqM(-vj_iykw69 zL;=9S%4B~JHcbi}XSJaOWX}*vg7yF&P$)PHOf*b>Q*zL8E9mHzs{e;aMvfl3 z&;8fw>|to7qv8PZ(qrqfjBAm`(-a#rwMZg^h@?(K#qx66di!Rrx*p0rDkH76n&9a} z6e%cTwXk}~&ItCDAP0HyYS8sg4IsL<`YP53hc9m5+u&G#VYA0$r#3Zl^u9t^Fo1X+ zjNmTfckuqY!y{oarC|xLU9ib>L53li#yc;|t9AEWX~R z@s%+$>w-hm@`+^| zF;y&i@l@d^`*odC$EsYXtQ*-(d9MH9dFmEZSvOPQ23b3UDz|2{u1(8vT5@fV+a|?; z5iufj{n3?`vy&6U=H&Rq>ae*ob$Sf1C(f*{MvTFsQ`6VxmYkeR*Le&;quj9qKM1ZpJ_upGC<|l1h(sKX+ delta 175 zcmZp1XfcprU|?W$DortDU=RQ@Ie-{Mvv5r;6q~50D9R3!2aD-4afef4DdFC(!0OA)T4FCWD diff --git a/VERSION b/VERSION index 06edb38..b02da5e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -4.26.0 +4.26.1 diff --git a/env.example b/env.example index 2951ab9..fb24704 100644 --- a/env.example +++ b/env.example @@ -62,6 +62,26 @@ TODOIST_CLIENT_SECRET= # Получить в Developer Console: "Client secret for webhooks" TODOIST_WEBHOOK_SECRET= +# ============================================ +# Fitbit Integration Configuration +# ============================================ +# Fitbit приложение для интеграции с Play Life +# Настроить в: https://dev.fitbit.com/apps +# +# В настройках Fitbit приложения указать: +# - OAuth 2.0 Application Type: Server +# - Callback URL: /api/integrations/fitbit/oauth/callback +# - Default Access Type: Read-Only +# - Scopes: activity, profile +# - Terms of Service URL: /terms +# - Privacy Policy URL: /privacy + +# Client ID Fitbit приложения +FITBIT_CLIENT_ID= + +# Client Secret Fitbit приложения +FITBIT_CLIENT_SECRET= + # ============================================ # Authentication Configuration # ============================================ diff --git a/play-life-backend/main.go b/play-life-backend/main.go index 514c99e..0afe99f 100644 --- a/play-life-backend/main.go +++ b/play-life-backend/main.go @@ -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 запускает планировщик для автовыполнения задач в конце дня // каждый день в 23:55 в указанном часовом поясе func (a *App) startEndOfDayTaskScheduler() { @@ -4078,6 +4144,9 @@ func main() { // Запускаем планировщик для автовыполнения задач в конце дня в 23:55 app.startEndOfDayTaskScheduler() + // Запускаем планировщик синхронизации Fitbit каждые 4 часа + app.startFitbitSyncScheduler() + r := mux.NewRouter() // 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/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 protected.HandleFunc("/api/tasks", app.getTasksHandler).Methods("GET", "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 // ============================================ diff --git a/play-life-backend/migrations/000015_add_fitbit_integration.down.sql b/play-life-backend/migrations/000015_add_fitbit_integration.down.sql new file mode 100644 index 0000000..71f660d --- /dev/null +++ b/play-life-backend/migrations/000015_add_fitbit_integration.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS fitbit_daily_stats; +DROP TABLE IF EXISTS fitbit_integrations; diff --git a/play-life-backend/migrations/000015_add_fitbit_integration.up.sql b/play-life-backend/migrations/000015_add_fitbit_integration.up.sql new file mode 100644 index 0000000..82a51e5 --- /dev/null +++ b/play-life-backend/migrations/000015_add_fitbit_integration.up.sql @@ -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); diff --git a/play-life-web/nginx.conf b/play-life-web/nginx.conf index 258de2e..0564c79 100644 --- a/play-life-web/nginx.conf +++ b/play-life-web/nginx.conf @@ -86,6 +86,17 @@ server { 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) location / { try_files $uri $uri/ /index.html; diff --git a/play-life-web/package.json b/play-life-web/package.json index 3113baf..b1f0692 100644 --- a/play-life-web/package.json +++ b/play-life-web/package.json @@ -1,6 +1,6 @@ { "name": "play-life-web", - "version": "4.26.0", + "version": "4.26.1", "type": "module", "scripts": { "dev": "vite", diff --git a/play-life-web/public/privacy.html b/play-life-web/public/privacy.html new file mode 100644 index 0000000..ecbd20d --- /dev/null +++ b/play-life-web/public/privacy.html @@ -0,0 +1,159 @@ + + + + + + Политика конфиденциальности - Play Life + + + +
+

Политика конфиденциальности

+ +

Дата вступления в силу: 1 января 2024 года

+ +

1. Введение

+

Play Life ("мы", "наш", "нас") уважает вашу конфиденциальность и обязуется защищать ваши личные данные. Настоящая Политика конфиденциальности объясняет, как мы собираем, используем, храним и защищаем вашу информацию при использовании нашего приложения.

+ +

2. Собираемая информация

+

Мы собираем следующие типы информации:

+ +

2.1. Информация, предоставляемая вами

+
    +
  • Имя и адрес электронной почты при регистрации
  • +
  • Данные о ваших проектах, задачах и целях
  • +
  • Списки желаний и связанная информация
  • +
  • Словари и слова для изучения
  • +
+ +

2.2. Информация из интеграций

+
    +
  • Todoist: Информация о ваших задачах (только при подключении интеграции)
  • +
  • Telegram: ID пользователя Telegram (только при подключении бота)
  • +
  • Fitbit: Данные о физической активности, включая шаги, этажи и активные зоны минут (только при подключении интеграции)
  • +
+ +

2.3. Автоматически собираемая информация

+
    +
  • Данные об использовании приложения (логи доступа, ошибки)
  • +
  • Техническая информация (версия браузера, тип устройства)
  • +
+ +

3. Использование информации

+

Мы используем собранную информацию для:

+
    +
  • Предоставления и улучшения функциональности приложения
  • +
  • Обработки ваших запросов и транзакций
  • +
  • Отправки уведомлений и обновлений (если вы подписаны)
  • +
  • Обеспечения безопасности и предотвращения мошенничества
  • +
  • Соблюдения юридических обязательств
  • +
+ +

4. Хранение данных

+

Ваши данные хранятся на защищенных серверах. Мы применяем соответствующие технические и организационные меры для защиты ваших данных от несанкционированного доступа, изменения, раскрытия или уничтожения.

+ +

5. Обмен данными

+

Мы не продаем и не передаем ваши личные данные третьим лицам, за исключением:

+
    +
  • Когда это необходимо для предоставления услуг (например, интеграции с Fitbit, Todoist, Telegram)
  • +
  • Когда это требуется по закону или по запросу государственных органов
  • +
  • С вашего явного согласия
  • +
+ +

6. Интеграции с третьими сторонами

+

При использовании интеграций с Fitbit, Todoist или Telegram, ваши данные могут передаваться этим сервисам в соответствии с их политиками конфиденциальности:

+
    +
  • Fitbit: Мы получаем доступ только к данным о физической активности (шаги, этажи, активные зоны минут) с вашего явного разрешения через OAuth.
  • +
  • Todoist: Мы получаем доступ только к информации о завершенных задачах для синхронизации с вашими проектами.
  • +
  • Telegram: Мы получаем только ваш Telegram ID для связи с ботом.
  • +
+ +

7. Ваши права

+

Вы имеете право:

+
    +
  • Получить доступ к вашим личным данным
  • +
  • Исправить неточные данные
  • +
  • Удалить ваши данные
  • +
  • Отозвать согласие на обработку данных
  • +
  • Ограничить обработку ваших данных
  • +
  • Получить копию ваших данных в структурированном формате
  • +
+

Для осуществления этих прав свяжитесь с нами через приложение.

+ +

8. Cookies и аналогичные технологии

+

Мы используем cookies и аналогичные технологии для улучшения работы приложения, анализа использования и персонализации контента. Вы можете управлять настройками cookies в вашем браузере.

+ +

9. Безопасность

+

Мы применяем различные меры безопасности для защиты ваших данных, включая шифрование, контроль доступа и регулярные проверки безопасности. Однако ни один метод передачи через Интернет или электронного хранения не является на 100% безопасным.

+ +

10. Хранение данных

+

Мы храним ваши данные до тех пор, пока это необходимо для предоставления услуг или до тех пор, пока вы не попросите нас удалить их. Некоторые данные могут храниться дольше в соответствии с требованиями законодательства.

+ +

11. Дети

+

Наше приложение не предназначено для лиц младше 13 лет. Мы сознательно не собираем личную информацию от детей младше 13 лет.

+ +

12. Изменения в политике

+

Мы можем периодически обновлять настоящую Политику конфиденциальности. Мы уведомим вас о любых существенных изменениях, разместив новую политику на этой странице и обновив дату "Последнее обновление".

+ +

13. Контактная информация

+

Если у вас есть вопросы или запросы относительно настоящей Политики конфиденциальности или обработки ваших данных, пожалуйста, свяжитесь с нами через приложение.

+ +

14. Применимое законодательство

+

Настоящая Политика конфиденциальности регулируется законодательством Российской Федерации, включая Федеральный закон "О персональных данных" № 152-ФЗ.

+ +
+

Последнее обновление: 1 января 2024 года

+
+
+ + diff --git a/play-life-web/public/terms.html b/play-life-web/public/terms.html new file mode 100644 index 0000000..252f4ff --- /dev/null +++ b/play-life-web/public/terms.html @@ -0,0 +1,128 @@ + + + + + + Условия использования - Play Life + + + +
+

Условия использования

+ +

Дата вступления в силу: 1 января 2024 года

+ +

1. Принятие условий

+

Используя приложение Play Life, вы соглашаетесь с настоящими Условиями использования. Если вы не согласны с какими-либо условиями, пожалуйста, не используйте наше приложение.

+ +

2. Описание сервиса

+

Play Life — это приложение для отслеживания продуктивности и личных целей, которое позволяет пользователям:

+
    +
  • Отслеживать прогресс по проектам и задачам
  • +
  • Управлять списками желаний
  • +
  • Изучать слова и создавать словари
  • +
  • Интегрироваться с внешними сервисами (Todoist, Telegram, Fitbit)
  • +
+ +

3. Регистрация и учетные записи

+

Для использования некоторых функций приложения требуется создание учетной записи. Вы обязуетесь:

+
    +
  • Предоставлять точную и актуальную информацию
  • +
  • Поддерживать безопасность вашей учетной записи
  • +
  • Нести ответственность за все действия, совершенные под вашей учетной записью
  • +
  • Немедленно уведомлять нас о любом несанкционированном использовании
  • +
+ +

4. Использование сервиса

+

Вы соглашаетесь использовать Play Life только в законных целях и не будете:

+
    +
  • Нарушать какие-либо применимые законы или нормативные акты
  • +
  • Передавать вредоносное программное обеспечение или код
  • +
  • Пытаться получить несанкционированный доступ к сервису
  • +
  • Использовать сервис для спама или рассылки нежелательных сообщений
  • +
  • Нарушать права интеллектуальной собственности других лиц
  • +
+ +

5. Интеграции с третьими сторонами

+

Play Life может интегрироваться с внешними сервисами (Todoist, Telegram, Fitbit). Использование этих интеграций регулируется условиями использования соответствующих сервисов. Мы не несем ответственности за действия или политики этих третьих сторон.

+ +

6. Интеллектуальная собственность

+

Все материалы, содержащиеся в Play Life, включая, но не ограничиваясь текстом, графикой, логотипами, иконками, изображениями, являются собственностью Play Life или их соответствующих владельцев и защищены законами об авторском праве.

+ +

7. Конфиденциальность

+

Использование ваших личных данных регулируется нашей Политикой конфиденциальности. Используя Play Life, вы соглашаетесь с обработкой ваших данных в соответствии с этой политикой.

+ +

8. Отказ от ответственности

+

Play Life предоставляется "как есть" без каких-либо гарантий, явных или подразумеваемых. Мы не гарантируем, что сервис будет бесперебойным, безопасным или безошибочным.

+ +

9. Ограничение ответственности

+

В максимальной степени, разрешенной законом, Play Life не несет ответственности за любые прямые, косвенные, случайные, особые или последующие убытки, возникающие в результате использования или невозможности использования сервиса.

+ +

10. Изменения в условиях

+

Мы оставляем за собой право изменять настоящие Условия использования в любое время. Изменения вступают в силу с момента их публикации. Продолжение использования сервиса после внесения изменений означает ваше согласие с новыми условиями.

+ +

11. Прекращение использования

+

Мы можем приостановить или прекратить ваш доступ к сервису в любое время, с уведомлением или без него, по любой причине, включая нарушение настоящих Условий использования.

+ +

12. Применимое право

+

Настоящие Условия использования регулируются и толкуются в соответствии с законодательством Российской Федерации.

+ +

13. Контактная информация

+

Если у вас есть вопросы относительно настоящих Условий использования, пожалуйста, свяжитесь с нами через приложение.

+ +
+

Последнее обновление: 1 января 2024 года

+
+
+ + diff --git a/play-life-web/src/App.jsx b/play-life-web/src/App.jsx index 3381669..f1d468b 100644 --- a/play-life-web/src/App.jsx +++ b/play-life-web/src/App.jsx @@ -16,6 +16,7 @@ import BoardForm from './components/BoardForm' import BoardJoinPreview from './components/BoardJoinPreview' import TodoistIntegration from './components/TodoistIntegration' import TelegramIntegration from './components/TelegramIntegration' +import FitbitIntegration from './components/FitbitIntegration' import Tracking from './components/Tracking' import TrackingAccess from './components/TrackingAccess' import TrackingInviteAccept from './components/TrackingInviteAccept' @@ -29,24 +30,13 @@ const FULL_STATISTICS_API_URL = '/d2dc349a-0d13-49b2-a8f0-1ab094bfba9b' // Определяем основные табы (без крестика) и глубокие табы (с крестиком) 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() { const { authFetch, isAuthenticated, loading: authLoading } = useAuth() + const prevIsAuthenticatedRef = useRef(null) - // Show loading while checking auth - if (authLoading) { - return ( -
-
Загрузка...
-
- ) - } - - // Show auth screen if not authenticated - if (!isAuthenticated) { - return - } + // Все хуки должны быть объявлены до условных возвратов const [activeTab, setActiveTab] = useState('current') const [selectedProject, setSelectedProject] = useState(null) const [loadedTabs, setLoadedTabs] = useState({ @@ -67,6 +57,7 @@ function AppContent() { profile: false, 'todoist-integration': false, 'telegram-integration': false, + 'fitbit-integration': false, tracking: false, 'tracking-access': false, 'tracking-invite': false, @@ -91,6 +82,7 @@ function AppContent() { profile: false, 'todoist-integration': false, 'telegram-integration': false, + 'fitbit-integration': false, tracking: false, 'tracking-access': false, 'tracking-invite': false, @@ -147,6 +139,36 @@ function AppContent() { // Восстанавливаем последний выбранный таб после перезагрузки 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 useEffect(() => { if (isInitialized) return @@ -183,7 +205,7 @@ function AppContent() { // Проверяем URL только для глубоких табов const urlParams = new URLSearchParams(window.location.search) 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)) { // Если в URL есть глубокий таб, восстанавливаем его @@ -494,6 +516,7 @@ function AppContent() { profile: false, 'todoist-integration': false, 'telegram-integration': false, + 'fitbit-integration': false, tracking: false, 'tracking-access': false, 'tracking-invite': false, @@ -507,6 +530,10 @@ function AppContent() { todayEntries: null, }) + // Refs для отслеживания активного таба + const prevActiveTabRef = useRef(null) + const lastLoadedTabRef = useRef(null) // Отслеживаем последний загруженный таб, чтобы избежать двойной загрузки + // Обновляем ref при изменении данных useEffect(() => { cacheRef.current.current = currentWeekData @@ -886,9 +913,6 @@ function AppContent() { } // Загружаем данные при открытии таба (когда таб становится активным) - const prevActiveTabRef = useRef(null) - const lastLoadedTabRef = useRef(null) // Отслеживаем последний загруженный таб, чтобы избежать двойной загрузки - useEffect(() => { if (!activeTab || !loadedTabs[activeTab]) return @@ -946,8 +970,23 @@ function AppContent() { } }, [activeTab]) + // Show loading while checking auth + if (authLoading) { + return ( +
+
Загрузка...
+
+ ) + } + + // Show auth screen if not authenticated + if (!isAuthenticated) { + prevIsAuthenticatedRef.current = false + return + } + // Определяем, нужно ли скрывать нижнюю панель (для 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() { )} + {loadedTabs['fitbit-integration'] && ( +
+
+ +
+
+ )} + {loadedTabs.tracking && (
diff --git a/play-life-web/src/components/FitbitIntegration.jsx b/play-life-web/src/components/FitbitIntegration.jsx new file mode 100644 index 0000000..bbd44cf --- /dev/null +++ b/play-life-web/src/components/FitbitIntegration.jsx @@ -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 ( +
+ + +
+ ) + } + + return ( +
+ + +

Fitbit интеграция

+ + {loading ? ( +
+
+
+
Загрузка...
+
+
+ ) : connected ? ( +
+ {message && ( +
+

{message}

+
+ )} + + {/* Статистика */} +
+
+

Статистика за сегодня

+ +
+ + {/* Шаги */} +
+
+ Шаги + + {stats.steps.value.toLocaleString()} / {stats.steps.goal.min}-{stats.steps.goal.max} + +
+
+
+
+
+ + {/* Этажи */} +
+
+ Этажи + + {stats.floors.value} / {stats.floors.goal.min}-{stats.floors.goal.max} + +
+
+
+
+
+ + {/* Баллы кардио (AZM) */} +
+
+ Баллы кардио + + {stats.azm.value} / {stats.azm.goal.min}-{stats.azm.goal.max} + +
+
+
+
+
+
+ + {/* Настройка целей */} +
+
+

Дневные цели

+ {!isEditingGoals && ( + + )} +
+ + {isEditingGoals ? ( +
+ {/* Шаги */} +
+ +
+ setEditedGoals({ ...editedGoals, steps: { ...editedGoals.steps, min: parseInt(e.target.value) || 0 } })} + className="flex-1 px-3 py-2 border border-gray-300 rounded-lg" + /> + setEditedGoals({ ...editedGoals, steps: { ...editedGoals.steps, max: parseInt(e.target.value) || 0 } })} + className="flex-1 px-3 py-2 border border-gray-300 rounded-lg" + /> +
+
+ + {/* Этажи */} +
+ +
+ setEditedGoals({ ...editedGoals, floors: { ...editedGoals.floors, min: parseInt(e.target.value) || 0 } })} + className="flex-1 px-3 py-2 border border-gray-300 rounded-lg" + /> + setEditedGoals({ ...editedGoals, floors: { ...editedGoals.floors, max: parseInt(e.target.value) || 0 } })} + className="flex-1 px-3 py-2 border border-gray-300 rounded-lg" + /> +
+
+ + {/* Баллы кардио */} +
+ +
+ setEditedGoals({ ...editedGoals, azm: { ...editedGoals.azm, min: parseInt(e.target.value) || 0 } })} + className="flex-1 px-3 py-2 border border-gray-300 rounded-lg" + /> + setEditedGoals({ ...editedGoals, azm: { ...editedGoals.azm, max: parseInt(e.target.value) || 0 } })} + className="flex-1 px-3 py-2 border border-gray-300 rounded-lg" + /> +
+
+ +
+ + +
+
+ ) : ( +
+
+ Шаги: + {goals.steps.min} - {goals.steps.max} +
+
+ Этажи: + {goals.floors.min} - {goals.floors.max} +
+
+ Баллы кардио: + {goals.azm.min} - {goals.azm.max} +
+
+ )} +
+ +
+

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

+

+ ✅ Fitbit подключен! Данные синхронизируются автоматически каждые 4 часа. +

+

+ Вы также можете синхронизировать данные вручную, нажав кнопку "Синхронизировать". +

+
+ + +
+ ) : ( +
+
+

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

+

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

+ +
+ +
+

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

+
    +
  1. Нажмите кнопку "Подключить Fitbit"
  2. +
  3. Авторизуйтесь в Fitbit
  4. +
  5. Разрешите доступ к данным о физической активности
  6. +
  7. Готово! Данные будут синхронизироваться автоматически
  8. +
+
+
+ )} + {toastMessage && ( + setToastMessage(null)} + /> + )} +
+ ) +} + +export default FitbitIntegration diff --git a/play-life-web/src/components/Profile.jsx b/play-life-web/src/components/Profile.jsx index 3634a3a..41024ec 100644 --- a/play-life-web/src/components/Profile.jsx +++ b/play-life-web/src/components/Profile.jsx @@ -8,6 +8,7 @@ function Profile({ onNavigate }) { const integrations = [ { id: 'todoist-integration', name: 'TODOist' }, { id: 'telegram-integration', name: 'Telegram' }, + { id: 'fitbit-integration', name: 'Fitbit' }, ] const handleLogout = async () => {