diff --git a/VERSION b/VERSION index bb09d45..831446c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -5.0.9 +5.1.0 diff --git a/play-life-backend/main.go b/play-life-backend/main.go index f1133e4..21afc97 100644 --- a/play-life-backend/main.go +++ b/play-life-backend/main.go @@ -4271,7 +4271,7 @@ func main() { 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/bindings", app.updateFitbitBindingsHandler).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") @@ -10489,12 +10489,23 @@ func (a *App) getFitbitStatusHandler(w http.ResponseWriter, r *http.Request) { } var fitbitUserID sql.NullString - var goalStepsMin, goalStepsMax, goalFloorsMin, goalFloorsMax, goalAzmMin, goalAzmMax sql.NullInt64 + var stepsTaskID, floorsTaskID sql.NullInt64 + var stepsGoalTaskID, stepsGoalSubtaskID sql.NullInt64 + var floorsGoalTaskID, floorsGoalSubtaskID 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 + SELECT fitbit_user_id, + steps_task_id, floors_task_id, + steps_goal_task_id, steps_goal_subtask_id, + floors_goal_task_id, floors_goal_subtask_id + FROM fitbit_integrations WHERE user_id = $1 AND access_token IS NOT NULL - `, userID).Scan(&fitbitUserID, &goalStepsMin, &goalStepsMax, &goalFloorsMin, &goalFloorsMax, &goalAzmMin, &goalAzmMax) + `, userID).Scan( + &fitbitUserID, + &stepsTaskID, &floorsTaskID, + &stepsGoalTaskID, &stepsGoalSubtaskID, + &floorsGoalTaskID, &floorsGoalSubtaskID, + ) if err == sql.ErrNoRows || !fitbitUserID.Valid { w.Header().Set("Content-Type", "application/json") @@ -10508,21 +10519,38 @@ func (a *App) getFitbitStatusHandler(w http.ResponseWriter, r *http.Request) { return } + var stepsTaskIDValue, floorsTaskIDValue interface{} + var stepsGoalTaskIDValue, stepsGoalSubtaskIDValue interface{} + var floorsGoalTaskIDValue, floorsGoalSubtaskIDValue interface{} + + if stepsTaskID.Valid { + stepsTaskIDValue = stepsTaskID.Int64 + } + if floorsTaskID.Valid { + floorsTaskIDValue = floorsTaskID.Int64 + } + if stepsGoalTaskID.Valid { + stepsGoalTaskIDValue = stepsGoalTaskID.Int64 + } + if stepsGoalSubtaskID.Valid { + stepsGoalSubtaskIDValue = stepsGoalSubtaskID.Int64 + } + if floorsGoalTaskID.Valid { + floorsGoalTaskIDValue = floorsGoalTaskID.Int64 + } + if floorsGoalSubtaskID.Valid { + floorsGoalSubtaskIDValue = floorsGoalSubtaskID.Int64 + } + 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, - }, + "bindings": map[string]interface{}{ + "steps_task_id": stepsTaskIDValue, + "floors_task_id": floorsTaskIDValue, + "steps_goal_task_id": stepsGoalTaskIDValue, + "steps_goal_subtask_id": stepsGoalSubtaskIDValue, + "floors_goal_task_id": floorsGoalTaskIDValue, + "floors_goal_subtask_id": floorsGoalSubtaskIDValue, }, } @@ -10564,8 +10592,8 @@ func (a *App) fitbitDisconnectHandler(w http.ResponseWriter, r *http.Request) { }) } -// updateFitbitGoalsHandler обновляет цели пользователя -func (a *App) updateFitbitGoalsHandler(w http.ResponseWriter, r *http.Request) { +// updateFitbitBindingsHandler обновляет привязки задач для Fitbit +func (a *App) updateFitbitBindingsHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) @@ -10580,9 +10608,12 @@ func (a *App) updateFitbitGoalsHandler(w http.ResponseWriter, r *http.Request) { } var req struct { - Steps map[string]int64 `json:"steps"` - Floors map[string]int64 `json:"floors"` - Azm map[string]int64 `json:"azm"` + StepsTaskID *int `json:"steps_task_id"` + FloorsTaskID *int `json:"floors_task_id"` + StepsGoalTaskID *int `json:"steps_goal_task_id"` + StepsGoalSubtaskID *int `json:"steps_goal_subtask_id"` + FloorsGoalTaskID *int `json:"floors_goal_task_id"` + FloorsGoalSubtaskID *int `json:"floors_goal_subtask_id"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -10590,28 +10621,99 @@ func (a *App) updateFitbitGoalsHandler(w http.ResponseWriter, r *http.Request) { return } + validateTask := func(taskID *int, fieldName string) error { + if taskID == nil { + return nil + } + var exists bool + err := a.DB.QueryRow(` + SELECT EXISTS(SELECT 1 FROM tasks WHERE id = $1 AND user_id = $2 AND deleted = FALSE) + `, *taskID, userID).Scan(&exists) + if err != nil || !exists { + return fmt.Errorf("%s: task %d not found", fieldName, *taskID) + } + return nil + } + + validateSubtask := func(subtaskID *int, parentTaskID *int, fieldName string) error { + if subtaskID == nil { + return nil + } + if parentTaskID == nil { + return fmt.Errorf("%s: parent task is required", fieldName) + } + var exists bool + err := a.DB.QueryRow(` + SELECT EXISTS(SELECT 1 FROM tasks WHERE id = $1 AND parent_task_id = $2 AND user_id = $3 AND deleted = FALSE) + `, *subtaskID, *parentTaskID, userID).Scan(&exists) + if err != nil || !exists { + return fmt.Errorf("%s: subtask %d is not a child of task %d", fieldName, *subtaskID, *parentTaskID) + } + return nil + } + + if err := validateTask(req.StepsTaskID, "steps_task_id"); err != nil { + sendErrorWithCORS(w, err.Error(), http.StatusBadRequest) + return + } + if err := validateTask(req.FloorsTaskID, "floors_task_id"); err != nil { + sendErrorWithCORS(w, err.Error(), http.StatusBadRequest) + return + } + if err := validateTask(req.StepsGoalTaskID, "steps_goal_task_id"); err != nil { + sendErrorWithCORS(w, err.Error(), http.StatusBadRequest) + return + } + if err := validateTask(req.FloorsGoalTaskID, "floors_goal_task_id"); err != nil { + sendErrorWithCORS(w, err.Error(), http.StatusBadRequest) + return + } + if err := validateSubtask(req.StepsGoalSubtaskID, req.StepsGoalTaskID, "steps_goal_subtask_id"); err != nil { + sendErrorWithCORS(w, err.Error(), http.StatusBadRequest) + return + } + if err := validateSubtask(req.FloorsGoalSubtaskID, req.FloorsGoalTaskID, "floors_goal_subtask_id"); err != nil { + sendErrorWithCORS(w, err.Error(), http.StatusBadRequest) + return + } + + toNullInt64 := func(v *int) sql.NullInt64 { + if v == nil { + return sql.NullInt64{Valid: false} + } + return sql.NullInt64{Int64: int64(*v), Valid: true} + } + _, 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, + SET steps_task_id = $1, + floors_task_id = $2, + steps_goal_task_id = $3, + steps_goal_subtask_id = $4, + floors_goal_task_id = $5, + floors_goal_subtask_id = $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) + `, + toNullInt64(req.StepsTaskID), + toNullInt64(req.FloorsTaskID), + toNullInt64(req.StepsGoalTaskID), + toNullInt64(req.StepsGoalSubtaskID), + toNullInt64(req.FloorsGoalTaskID), + toNullInt64(req.FloorsGoalSubtaskID), + 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) + log.Printf("Fitbit update bindings: DB error: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Failed to update bindings: %v", err), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "success": true, - "message": "Goals updated", + "message": "Bindings updated", }) } @@ -10717,59 +10819,216 @@ func (a *App) syncFitbitData(userID int, date time.Time) error { 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) + // Получаем цели пользователя из Fitbit API + goalsURL := "https://api.fitbit.com/1/user/-/activities/goals/daily.json" + reqGoals, err := http.NewRequest("GET", goalsURL, nil) if err != nil { - return fmt.Errorf("failed to create AZM request: %w", err) + return fmt.Errorf("failed to create goals request: %w", err) } + reqGoals.Header.Set("Authorization", "Bearer "+accessToken) + reqGoals.Header.Set("Accept", "application/json") - reqAZM.Header.Set("Authorization", "Bearer "+accessToken) - reqAZM.Header.Set("Accept", "application/json") - - respAZM, err := client.Do(reqAZM) + respGoals, err := client.Do(reqGoals) if err != nil { - return fmt.Errorf("failed to get AZM data: %w", err) + return fmt.Errorf("failed to get goals data: %w", err) } - defer respAZM.Body.Close() + defer respGoals.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"` + var goalSteps, goalFloors int + if respGoals.StatusCode == http.StatusOK { + bodyBytesGoals, _ := io.ReadAll(respGoals.Body) + var goalsData struct { + Goals struct { + Steps int `json:"steps"` + Floors int `json:"floors"` + } `json:"goals"` } - - if err := json.Unmarshal(bodyBytesAZM, &azmData); err == nil { - if len(azmData.ActivitiesActiveZoneMinutes) > 0 { - azmValue = azmData.ActivitiesActiveZoneMinutes[0].Value.ActiveZoneMinutes - } + if err := json.Unmarshal(bodyBytesGoals, &goalsData); err == nil { + goalSteps = goalsData.Goals.Steps + goalFloors = goalsData.Goals.Floors } } + if goalSteps == 0 { + goalSteps = 10000 + } + if goalFloors == 0 { + goalFloors = 10 + } // Сохраняем данные в БД _, 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) + INSERT INTO fitbit_daily_stats (user_id, date, steps, floors, goal_steps, goal_floors, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, CURRENT_TIMESTAMP) ON CONFLICT (user_id, date) DO UPDATE SET steps = $3, floors = $4, - active_zone_minutes = $5, + goal_steps = $5, + goal_floors = $6, updated_at = CURRENT_TIMESTAMP - `, userID, dateStr, activityData.Summary.Steps, activityData.Summary.Floors, azmValue) + `, userID, dateStr, activityData.Summary.Steps, activityData.Summary.Floors, goalSteps, goalFloors) 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) + // Получаем привязки задач из fitbit_integrations + var stepsTaskID, floorsTaskID sql.NullInt64 + var stepsGoalTaskID, stepsGoalSubtaskID sql.NullInt64 + var floorsGoalTaskID, floorsGoalSubtaskID sql.NullInt64 + err = a.DB.QueryRow(` + SELECT steps_task_id, floors_task_id, + steps_goal_task_id, steps_goal_subtask_id, + floors_goal_task_id, floors_goal_subtask_id + FROM fitbit_integrations + WHERE user_id = $1 + `, userID).Scan( + &stepsTaskID, &floorsTaskID, + &stepsGoalTaskID, &stepsGoalSubtaskID, + &floorsGoalTaskID, &floorsGoalSubtaskID, + ) + if err != nil && err != sql.ErrNoRows { + log.Printf("Error getting fitbit bindings: %v", err) + } + + steps := activityData.Summary.Steps + floors := activityData.Summary.Floors + + if stepsTaskID.Valid { + if err := a.saveFitbitProgressDraft(userID, int(stepsTaskID.Int64), float64(steps)); err != nil { + log.Printf("Error saving steps draft: %v", err) + } + } + if floorsTaskID.Valid { + if err := a.saveFitbitProgressDraft(userID, int(floorsTaskID.Int64), float64(floors)); err != nil { + log.Printf("Error saving floors draft: %v", err) + } + } + if stepsGoalTaskID.Valid && stepsGoalSubtaskID.Valid { + goalReached := steps >= goalSteps + if err := a.saveFitbitSubtaskDraft(userID, int(stepsGoalTaskID.Int64), int(stepsGoalSubtaskID.Int64), goalReached); err != nil { + log.Printf("Error saving steps goal subtask draft: %v", err) + } + } + if floorsGoalTaskID.Valid && floorsGoalSubtaskID.Valid { + goalReached := floors >= goalFloors + if err := a.saveFitbitSubtaskDraft(userID, int(floorsGoalTaskID.Int64), int(floorsGoalSubtaskID.Int64), goalReached); err != nil { + log.Printf("Error saving floors goal subtask draft: %v", err) + } + } + + log.Printf("Fitbit data synced for user_id=%d, date=%s: steps=%d, floors=%d, goalSteps=%d, goalFloors=%d", + userID, dateStr, activityData.Summary.Steps, activityData.Summary.Floors, goalSteps, goalFloors) + + return nil +} + +// saveFitbitProgressDraft сохраняет драфт задачи с прогрессом +// Устанавливает progression_value и auto_complete = true +func (a *App) saveFitbitProgressDraft(userID int, taskID int, progressionValue float64) error { + var exists bool + err := a.DB.QueryRow(` + SELECT EXISTS(SELECT 1 FROM tasks WHERE id = $1 AND user_id = $2 AND deleted = FALSE) + `, taskID, userID).Scan(&exists) + if err != nil || !exists { + return fmt.Errorf("task %d not found or not owned by user", taskID) + } + + var draftID int + err = a.DB.QueryRow("SELECT id FROM task_drafts WHERE task_id = $1", taskID).Scan(&draftID) + + if err == sql.ErrNoRows { + _, err = a.DB.Exec(` + INSERT INTO task_drafts (task_id, user_id, progression_value, auto_complete, created_at, updated_at) + VALUES ($1, $2, $3, TRUE, NOW(), NOW()) + `, taskID, userID, progressionValue) + } else if err == nil { + _, err = a.DB.Exec(` + UPDATE task_drafts + SET progression_value = $1, auto_complete = TRUE, updated_at = NOW() + WHERE id = $2 + `, progressionValue, draftID) + } + + if err != nil { + return fmt.Errorf("failed to save draft: %w", err) + } + + log.Printf("Fitbit: saved progress draft for task_id=%d, value=%.2f", taskID, progressionValue) + return nil +} + +// saveFitbitSubtaskDraft сохраняет драфт с checked/unchecked подзадачей +// taskID - родительская задача (для драфта) +// subtaskID - подзадача которую нужно отметить +// checked - true если цель достигнута, false если нет +func (a *App) saveFitbitSubtaskDraft(userID int, taskID int, subtaskID int, checked bool) error { + var exists bool + err := a.DB.QueryRow(` + SELECT EXISTS(SELECT 1 FROM tasks WHERE id = $1 AND user_id = $2 AND deleted = FALSE) + `, taskID, userID).Scan(&exists) + if err != nil || !exists { + return fmt.Errorf("task %d not found or not owned by user", taskID) + } + + err = a.DB.QueryRow(` + SELECT EXISTS(SELECT 1 FROM tasks WHERE id = $1 AND parent_task_id = $2 AND deleted = FALSE) + `, subtaskID, taskID).Scan(&exists) + if err != nil || !exists { + return fmt.Errorf("subtask %d is not a child of task %d", subtaskID, taskID) + } + + tx, err := a.DB.Begin() + if err != nil { + return fmt.Errorf("failed to begin transaction: %w", err) + } + defer tx.Rollback() + + var draftID int + err = tx.QueryRow("SELECT id FROM task_drafts WHERE task_id = $1", taskID).Scan(&draftID) + + if err == sql.ErrNoRows { + err = tx.QueryRow(` + INSERT INTO task_drafts (task_id, user_id, auto_complete, created_at, updated_at) + VALUES ($1, $2, TRUE, NOW(), NOW()) + RETURNING id + `, taskID, userID).Scan(&draftID) + if err != nil { + return fmt.Errorf("failed to create draft: %w", err) + } + } else if err != nil { + return fmt.Errorf("failed to check draft: %w", err) + } else { + _, err = tx.Exec(` + UPDATE task_drafts SET auto_complete = TRUE, updated_at = NOW() WHERE id = $1 + `, draftID) + if err != nil { + return fmt.Errorf("failed to update draft: %w", err) + } + } + + if checked { + _, err = tx.Exec(` + INSERT INTO task_draft_subtasks (task_draft_id, subtask_id) + VALUES ($1, $2) + ON CONFLICT (task_draft_id, subtask_id) DO NOTHING + `, draftID, subtaskID) + } else { + _, err = tx.Exec(` + DELETE FROM task_draft_subtasks + WHERE task_draft_id = $1 AND subtask_id = $2 + `, draftID, subtaskID) + } + + if err != nil { + return fmt.Errorf("failed to update subtask: %w", err) + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("failed to commit: %w", err) + } + + log.Printf("Fitbit: saved subtask draft for task_id=%d, subtask_id=%d, checked=%v", taskID, subtaskID, checked) return nil } @@ -10835,21 +11094,25 @@ func (a *App) getFitbitStatsHandler(w http.ResponseWriter, r *http.Request) { dateStr = time.Now().In(loc).Format("2006-01-02") } - var steps, floors, azm sql.NullInt64 + var steps, floors, goalSteps, goalFloors sql.NullInt64 err := a.DB.QueryRow(` - SELECT steps, floors, active_zone_minutes + SELECT steps, floors, goal_steps, goal_floors FROM fitbit_daily_stats WHERE user_id = $1 AND date = $2 - `, userID, dateStr).Scan(&steps, &floors, &azm) + `, userID, dateStr).Scan(&steps, &floors, &goalSteps, &goalFloors) 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, + "date": dateStr, + "steps": map[string]interface{}{ + "value": 0, + "goal": 10000, + }, + "floors": map[string]interface{}{ + "value": 0, + "goal": 10, + }, }) return } @@ -10859,22 +11122,13 @@ func (a *App) getFitbitStatsHandler(w http.ResponseWriter, r *http.Request) { 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} + goalStepsValue := goalSteps.Int64 + if !goalSteps.Valid || goalStepsValue == 0 { + goalStepsValue = 10000 + } + goalFloorsValue := goalFloors.Int64 + if !goalFloors.Valid || goalFloorsValue == 0 { + goalFloorsValue = 10 } w.Header().Set("Content-Type", "application/json") @@ -10882,24 +11136,11 @@ func (a *App) getFitbitStatsHandler(w http.ResponseWriter, r *http.Request) { "date": dateStr, "steps": map[string]interface{}{ "value": steps.Int64, - "goal": map[string]interface{}{ - "min": goalStepsMin.Int64, - "max": goalStepsMax.Int64, - }, + "goal": goalStepsValue, }, "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, - }, + "goal": goalFloorsValue, }, }) } diff --git a/play-life-backend/migrations/000021_fitbit_task_bindings.down.sql b/play-life-backend/migrations/000021_fitbit_task_bindings.down.sql new file mode 100644 index 0000000..7ca2a01 --- /dev/null +++ b/play-life-backend/migrations/000021_fitbit_task_bindings.down.sql @@ -0,0 +1,20 @@ +-- Откат: удаляем новые колонки +ALTER TABLE fitbit_integrations DROP COLUMN IF EXISTS steps_task_id; +ALTER TABLE fitbit_integrations DROP COLUMN IF EXISTS floors_task_id; +ALTER TABLE fitbit_integrations DROP COLUMN IF EXISTS steps_goal_task_id; +ALTER TABLE fitbit_integrations DROP COLUMN IF EXISTS steps_goal_subtask_id; +ALTER TABLE fitbit_integrations DROP COLUMN IF EXISTS floors_goal_task_id; +ALTER TABLE fitbit_integrations DROP COLUMN IF EXISTS floors_goal_subtask_id; + +ALTER TABLE fitbit_daily_stats DROP COLUMN IF EXISTS goal_steps; +ALTER TABLE fitbit_daily_stats DROP COLUMN IF EXISTS goal_floors; + +-- Восстанавливаем старые колонки +ALTER TABLE fitbit_daily_stats ADD COLUMN active_zone_minutes INTEGER DEFAULT 0; + +ALTER TABLE fitbit_integrations ADD COLUMN goal_steps_min INTEGER DEFAULT 8000; +ALTER TABLE fitbit_integrations ADD COLUMN goal_steps_max INTEGER DEFAULT 10000; +ALTER TABLE fitbit_integrations ADD COLUMN goal_floors_min INTEGER DEFAULT 8; +ALTER TABLE fitbit_integrations ADD COLUMN goal_floors_max INTEGER DEFAULT 10; +ALTER TABLE fitbit_integrations ADD COLUMN goal_azm_min INTEGER DEFAULT 22; +ALTER TABLE fitbit_integrations ADD COLUMN goal_azm_max INTEGER DEFAULT 44; diff --git a/play-life-backend/migrations/000021_fitbit_task_bindings.up.sql b/play-life-backend/migrations/000021_fitbit_task_bindings.up.sql new file mode 100644 index 0000000..cde93c2 --- /dev/null +++ b/play-life-backend/migrations/000021_fitbit_task_bindings.up.sql @@ -0,0 +1,42 @@ +-- ============================================= +-- Удаляем старые колонки целей (goals) из fitbit_integrations +-- Теперь цели берутся из Fitbit API +-- ============================================= +ALTER TABLE fitbit_integrations DROP COLUMN IF EXISTS goal_steps_min; +ALTER TABLE fitbit_integrations DROP COLUMN IF EXISTS goal_steps_max; +ALTER TABLE fitbit_integrations DROP COLUMN IF EXISTS goal_floors_min; +ALTER TABLE fitbit_integrations DROP COLUMN IF EXISTS goal_floors_max; +ALTER TABLE fitbit_integrations DROP COLUMN IF EXISTS goal_azm_min; +ALTER TABLE fitbit_integrations DROP COLUMN IF EXISTS goal_azm_max; + +-- ============================================= +-- Удаляем AZM колонку из fitbit_daily_stats +-- ============================================= +ALTER TABLE fitbit_daily_stats DROP COLUMN IF EXISTS active_zone_minutes; + +-- ============================================= +-- Добавляем колонки для кэширования целей из Fitbit API +-- ============================================= +ALTER TABLE fitbit_daily_stats ADD COLUMN goal_steps INTEGER; +ALTER TABLE fitbit_daily_stats ADD COLUMN goal_floors INTEGER; + +-- ============================================= +-- Добавляем привязки к задачам для записи прогресса +-- steps_task_id - задача куда записывать шаги как progression_value +-- floors_task_id - задача куда записывать этажи как progression_value +-- ============================================= +ALTER TABLE fitbit_integrations ADD COLUMN steps_task_id INTEGER REFERENCES tasks(id) ON DELETE SET NULL; +ALTER TABLE fitbit_integrations ADD COLUMN floors_task_id INTEGER REFERENCES tasks(id) ON DELETE SET NULL; + +-- ============================================= +-- Добавляем привязки для целей (goals) +-- Для каждой цели храним И задачу И подзадачу +-- steps_goal_task_id - родительская задача для цели шагов +-- steps_goal_subtask_id - подзадача внутри неё, которая будет checked/unchecked +-- floors_goal_task_id - родительская задача для цели этажей +-- floors_goal_subtask_id - подзадача внутри неё +-- ============================================= +ALTER TABLE fitbit_integrations ADD COLUMN steps_goal_task_id INTEGER REFERENCES tasks(id) ON DELETE SET NULL; +ALTER TABLE fitbit_integrations ADD COLUMN steps_goal_subtask_id INTEGER REFERENCES tasks(id) ON DELETE SET NULL; +ALTER TABLE fitbit_integrations ADD COLUMN floors_goal_task_id INTEGER REFERENCES tasks(id) ON DELETE SET NULL; +ALTER TABLE fitbit_integrations ADD COLUMN floors_goal_subtask_id INTEGER REFERENCES tasks(id) ON DELETE SET NULL; diff --git a/play-life-web/package.json b/play-life-web/package.json index 1838d75..f9b6e9c 100644 --- a/play-life-web/package.json +++ b/play-life-web/package.json @@ -1,6 +1,6 @@ { "name": "play-life-web", - "version": "5.0.9", + "version": "5.1.0", "type": "module", "scripts": { "dev": "vite", diff --git a/play-life-web/src/App.jsx b/play-life-web/src/App.jsx index cfe1aac..c647012 100644 --- a/play-life-web/src/App.jsx +++ b/play-life-web/src/App.jsx @@ -32,6 +32,22 @@ 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', 'fitbit-integration', 'full', 'priorities', 'tracking', 'tracking-access', 'tracking-invite'] +/** + * Гарантирует базовую запись истории для главного экрана перед глубоким табом. + * После долгого бездействия PWA может перезапуститься с одной записью в истории; + * кнопка "назад" тогда закрывает приложение. Эта функция добавляет запись для + * экрана 'current', чтобы "назад" возвращала на главный экран. + */ +function ensureBaseHistory(deepTab, params = {}, url) { + if (typeof window === 'undefined' || !deepTabs.includes(deepTab)) return + if (window.history.length <= 1) { + window.history.replaceState({ tab: 'current' }, '', '/') + window.history.pushState({ tab: deepTab, params, previousTab: 'current' }, '', url) + } else { + window.history.replaceState({ tab: deepTab, params, previousTab: 'current' }, '', url) + } +} + function AppContent() { const { authFetch, isAuthenticated, loading: authLoading } = useAuth() const prevIsAuthenticatedRef = useRef(null) @@ -179,12 +195,12 @@ function AppContent() { if (path.startsWith('/invite/')) { const token = path.replace('/invite/', '') if (token) { + const url = '/?tab=board-join&inviteToken=' + token + ensureBaseHistory('board-join', { inviteToken: token }, url) setActiveTab('board-join') setLoadedTabs(prev => ({ ...prev, 'board-join': true })) setTabParams({ inviteToken: token }) setIsInitialized(true) - // Очищаем путь, оставляем только параметры - window.history.replaceState({}, '', '/?tab=board-join&inviteToken=' + token) return } } @@ -193,11 +209,12 @@ function AppContent() { if (path.startsWith('/tracking/invite/')) { const token = path.replace('/tracking/invite/', '') if (token) { + const url = '/?tab=tracking-invite&inviteToken=' + token + ensureBaseHistory('tracking-invite', { inviteToken: token }, url) setActiveTab('tracking-invite') setLoadedTabs(prev => ({ ...prev, 'tracking-invite': true })) setTabParams({ inviteToken: token }) setIsInitialized(true) - window.history.replaceState({}, '', '/?tab=tracking-invite&inviteToken=' + token) return } } @@ -206,16 +223,18 @@ function AppContent() { const urlParams = new URLSearchParams(window.location.search) const integration = urlParams.get('integration') if (integration === 'fitbit') { - setActiveTab('fitbit-integration') - setLoadedTabs(prev => ({ ...prev, 'fitbit-integration': true })) - setIsInitialized(true) - // Перезаписываем URL с tab параметром и сохраняем integration/status для компонента const status = urlParams.get('status') const message = urlParams.get('message') let newUrl = '/?tab=fitbit-integration&integration=fitbit' if (status) newUrl += `&status=${status}` if (message) newUrl += `&message=${message}` - window.history.replaceState({}, '', newUrl) + const fitbitParams = { integration: 'fitbit' } + if (status) fitbitParams.status = status + if (message) fitbitParams.message = message + ensureBaseHistory('fitbit-integration', fitbitParams, newUrl) + setActiveTab('fitbit-integration') + setLoadedTabs(prev => ({ ...prev, 'fitbit-integration': true })) + setIsInitialized(true) return } @@ -224,10 +243,6 @@ function AppContent() { 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 есть глубокий таб, восстанавливаем его - setActiveTab(tabFromUrl) - setLoadedTabs(prev => ({ ...prev, [tabFromUrl]: true })) - // Восстанавливаем параметры из URL const params = {} urlParams.forEach((value, key) => { @@ -239,6 +254,11 @@ function AppContent() { } } }) + const deepTabUrl = window.location.pathname + window.location.search + ensureBaseHistory(tabFromUrl, params, deepTabUrl) + // Если в URL есть глубокий таб, восстанавливаем его + setActiveTab(tabFromUrl) + setLoadedTabs(prev => ({ ...prev, [tabFromUrl]: true })) if (Object.keys(params).length > 0) { setTabParams(params) // Если это экран full с selectedProject, восстанавливаем его diff --git a/play-life-web/src/components/FitbitIntegration.jsx b/play-life-web/src/components/FitbitIntegration.jsx index f3a5f6b..5cd06c8 100644 --- a/play-life-web/src/components/FitbitIntegration.jsx +++ b/play-life-web/src/components/FitbitIntegration.jsx @@ -13,25 +13,32 @@ function FitbitIntegration({ onNavigate }) { const [oauthError, setOauthError] = 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) - // Сохраняем OAuth статус из URL в ref, чтобы проверить после checkStatus + const [stats, setStats] = useState({ + steps: { value: 0, goal: 10000 }, + floors: { value: 0, goal: 10 } + }) + + const [bindings, setBindings] = useState({ + steps_task_id: null, + floors_task_id: null, + steps_goal_task_id: null, + steps_goal_subtask_id: null, + floors_goal_task_id: null, + floors_goal_subtask_id: null + }) + const [editedBindings, setEditedBindings] = useState(bindings) + const [savingBindings, setSavingBindings] = useState(false) + + const [tasks, setTasks] = useState([]) + const [loadingTasks, setLoadingTasks] = useState(false) + const [stepsGoalSubtasks, setStepsGoalSubtasks] = useState([]) + const [floorsGoalSubtasks, setFloorsGoalSubtasks] = useState([]) + const oauthStatusRef = React.useRef(null) useEffect(() => { - // Проверяем URL параметры для сообщений ДО вызова checkStatus const params = new URLSearchParams(window.location.search) const integration = params.get('integration') const status = params.get('status') @@ -52,7 +59,6 @@ function FitbitIntegration({ onNavigate }) { } setOauthError(errorMessages[errorMsg] || `Ошибка: ${errorMsg}`) } - // Очищаем URL параметры window.history.replaceState({}, '', window.location.pathname) } checkStatus() @@ -61,9 +67,52 @@ function FitbitIntegration({ onNavigate }) { useEffect(() => { if (connected) { loadStats() + loadTasks() } }, [connected]) + useEffect(() => { + if (!editedBindings.steps_goal_task_id) { + setStepsGoalSubtasks([]) + return + } + let cancelled = false + authFetch(`/api/tasks/${editedBindings.steps_goal_task_id}`) + .then((r) => r.ok ? r.json() : null) + .then((data) => { + if (!cancelled && data?.subtasks) { + setStepsGoalSubtasks(data.subtasks.map((s) => ({ id: s.task.id, name: s.task.name }))) + } else if (!cancelled) { + setStepsGoalSubtasks([]) + } + }) + .catch(() => { + if (!cancelled) setStepsGoalSubtasks([]) + }) + return () => { cancelled = true } + }, [editedBindings.steps_goal_task_id, authFetch]) + + useEffect(() => { + if (!editedBindings.floors_goal_task_id) { + setFloorsGoalSubtasks([]) + return + } + let cancelled = false + authFetch(`/api/tasks/${editedBindings.floors_goal_task_id}`) + .then((r) => r.ok ? r.json() : null) + .then((data) => { + if (!cancelled && data?.subtasks) { + setFloorsGoalSubtasks(data.subtasks.map((s) => ({ id: s.task.id, name: s.task.name }))) + } else if (!cancelled) { + setFloorsGoalSubtasks([]) + } + }) + .catch(() => { + if (!cancelled) setFloorsGoalSubtasks([]) + }) + return () => { cancelled = true } + }, [editedBindings.floors_goal_task_id, authFetch]) + const checkStatus = async () => { try { setLoading(true) @@ -74,13 +123,21 @@ function FitbitIntegration({ onNavigate }) { } const data = await response.json() setConnected(data.connected || false) - if (data.connected && data.goals) { - setGoals(data.goals) - setEditedGoals(data.goals) + if (data.connected && data.bindings) { + const b = data.bindings + const normalized = { + steps_task_id: b.steps_task_id ?? null, + floors_task_id: b.floors_task_id ?? null, + steps_goal_task_id: b.steps_goal_task_id ?? null, + steps_goal_subtask_id: b.steps_goal_subtask_id ?? null, + floors_goal_task_id: b.floors_goal_task_id ?? null, + floors_goal_subtask_id: b.floors_goal_subtask_id ?? null + } + setBindings(normalized) + setEditedBindings(normalized) } - // Если OAuth вернул status=connected, но бэкенд не подтвердил подключение if (oauthStatusRef.current === 'connected' && !data.connected) { - setOauthError('Авторизация в Fitbit прошла, но подключение не сохранилось. Попробуйте ещё раз или обратитесь к администратору.') + setOauthError('Авторизация в Fitbit прошла, но подключение не сохранилось. Попробуйте ещё раз.') setMessage('') } oauthStatusRef.current = null @@ -100,39 +157,34 @@ function FitbitIntegration({ onNavigate }) { throw new Error('Ошибка при загрузке статистики') } const data = await response.json() - // Нормализуем данные, чтобы избежать undefined - const defaultGoal = { min: 0, max: 0 } - const normalizedStats = { + setStats({ steps: { value: data.steps?.value ?? 0, - goal: data.steps?.goal ?? defaultGoal + goal: data.steps?.goal ?? 10000 }, floors: { value: data.floors?.value ?? 0, - goal: data.floors?.goal ?? defaultGoal - }, - azm: { - value: data.azm?.value ?? 0, - goal: data.azm?.goal ?? defaultGoal + goal: data.floors?.goal ?? 10 } - } - setStats(normalizedStats) - // Обновляем цели из ответа - if (data.steps?.goal) { - setGoals({ - steps: data.steps.goal, - floors: data.floors?.goal ?? defaultGoal, - azm: data.azm?.goal ?? defaultGoal - }) - setEditedGoals({ - steps: data.steps.goal, - floors: data.floors?.goal ?? defaultGoal, - azm: data.azm?.goal ?? defaultGoal - }) - } + }) } catch (error) { console.error('Error loading stats:', error) - // Не показываем ошибку, просто не обновляем статистику + } + } + + const loadTasks = async () => { + try { + setLoadingTasks(true) + const response = await authFetch('/api/tasks') + if (!response.ok) { + throw new Error('Ошибка при загрузке задач') + } + const data = await response.json() + setTasks(data || []) + } catch (error) { + console.error('Error loading tasks:', error) + } finally { + setLoadingTasks(false) } } @@ -162,7 +214,6 @@ function FitbitIntegration({ onNavigate }) { if (!window.confirm('Вы уверены, что хотите отключить Fitbit?')) { return } - try { setLoading(true) setError('') @@ -175,9 +226,8 @@ function FitbitIntegration({ onNavigate }) { } 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 } } + steps: { value: 0, goal: 10000 }, + floors: { value: 0, goal: 10 } }) setToastMessage({ text: 'Fitbit отключен', type: 'success' }) } catch (error) { @@ -208,47 +258,44 @@ function FitbitIntegration({ onNavigate }) { } } - const handleSaveGoals = async () => { + const handleSaveBindings = async () => { try { - const response = await authFetch('/api/integrations/fitbit/goals', { + setSavingBindings(true) + const response = await authFetch('/api/integrations/fitbit/bindings', { method: 'PUT', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - steps: editedGoals.steps, - floors: editedGoals.floors, - azm: editedGoals.azm, - }), + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(editedBindings) }) if (!response.ok) { const errorData = await response.json().catch(() => ({})) - throw new Error(errorData.error || 'Ошибка при сохранении целей') + throw new Error(errorData.error || 'Ошибка при сохранении привязок') } - setGoals(editedGoals) - setIsEditingGoals(false) - setToastMessage({ text: 'Цели сохранены', type: 'success' }) - await loadStats() + setBindings(editedBindings) + setToastMessage({ text: 'Привязки сохранены', type: 'success' }) } catch (error) { - console.error('Error saving goals:', error) - setToastMessage({ text: error.message || 'Не удалось сохранить цели', type: 'error' }) + console.error('Error saving bindings:', error) + setToastMessage({ text: error.message || 'Не удалось сохранить привязки', type: 'error' }) + } finally { + setSavingBindings(false) } } - const handleCancelEdit = () => { - setEditedGoals(goals) - setIsEditingGoals(false) + const getProgressTasks = () => { + return tasks.filter(t => t.has_progression || t.progression_base != null) } - 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 getParentTasks = () => { + return tasks.filter(t => (t.subtasks_count ?? 0) > 0) } - const getProgressColor = (value, min, max) => { - if (value >= max) return 'text-green-600' - if (value >= min) return 'text-blue-600' + const getProgressPercent = (value, goal) => { + if (!goal || goal === 0) return 0 + return Math.min(100, (value / goal) * 100) + } + + const getProgressColor = (value, goal) => { + if (value >= goal) return 'text-green-600' + if (value >= goal * 0.5) return 'text-blue-600' return 'text-gray-600' } @@ -269,207 +316,213 @@ function FitbitIntegration({ onNavigate }) { ✕ -

Fitbit интеграция

+

Fitbit

+ + {message && ( +
+

{message}

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

{message}

-
- )} - {oauthError && ( -
-

{oauthError}

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

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

+

Сегодня

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

Дневные цели

- {!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" - /> -
-
- -
- - -
-
+ {loadingTasks ? ( +
Загрузка задач...
) : ( -
-
- Шаги: - {goals.steps.min} - {goals.steps.max} +
+
+

Запись прогресса

+ +
+
+ + +
+ +
+ + +
+
-
- Этажи: - {goals.floors.min} - {goals.floors.max} + +
+

Отметка достижения цели по шагам

+ +
+
+ + +
+ +
+ + +
+
-
- Баллы кардио: - {goals.azm.min} - {goals.azm.max} + +
+

Отметка достижения цели по этажам

+ +
+
+ + +
+ +
+ + +
+
+ +
)}
-

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

-

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

-

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

+

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

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