5.1.0: Fitbit: привязки к задачам, цели из API
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m23s
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m23s
This commit is contained in:
@@ -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
|
||||
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,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "play-life-web",
|
||||
"version": "5.0.9",
|
||||
"version": "5.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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, восстанавливаем его
|
||||
|
||||
@@ -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 }) {
|
||||
✕
|
||||
</button>
|
||||
|
||||
<h1 className="text-2xl font-bold mb-6">Fitbit интеграция</h1>
|
||||
<h1 className="text-2xl font-bold mb-6">Fitbit</h1>
|
||||
|
||||
{message && (
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4 mb-6">
|
||||
<p className="text-green-800">{message}</p>
|
||||
<button onClick={() => setMessage('')} className="text-green-600 text-sm underline mt-2">Скрыть</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="fixed inset-0 flex justify-center items-center">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="w-12 h-12 border-4 border-indigo-200 border-t-indigo-600 rounded-full animate-spin mb-4"></div>
|
||||
<div className="text-gray-600 font-medium">Загрузка...</div>
|
||||
</div>
|
||||
<div className="flex justify-center items-center h-32">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600"></div>
|
||||
</div>
|
||||
) : connected ? (
|
||||
<div>
|
||||
{message && (
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4 mb-6">
|
||||
<p className="text-green-800">{message}</p>
|
||||
</div>
|
||||
)}
|
||||
{oauthError && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
|
||||
<p className="text-red-800">{oauthError}</p>
|
||||
<button onClick={() => setOauthError('')} className="text-red-600 text-sm underline mt-1">Скрыть</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Статистика */}
|
||||
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-lg font-semibold">Статистика за сегодня</h2>
|
||||
<h2 className="text-lg font-semibold">Сегодня</h2>
|
||||
<button
|
||||
onClick={handleSync}
|
||||
disabled={syncing}
|
||||
className="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed text-sm"
|
||||
className="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{syncing ? 'Синхронизация...' : 'Синхронизировать'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Шаги */}
|
||||
<div className="mb-6">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="text-gray-700 font-medium">Шаги</span>
|
||||
<span className={`font-bold ${getProgressColor(stats.steps?.value ?? 0, stats.steps?.goal?.min ?? 0, stats.steps?.goal?.max ?? 0)}`}>
|
||||
{(stats.steps?.value ?? 0).toLocaleString()} / {stats.steps?.goal?.min ?? 0}-{stats.steps?.goal?.max ?? 0}
|
||||
<span className={`font-bold ${getProgressColor(stats.steps.value, stats.steps.goal)}`}>
|
||||
{stats.steps.value.toLocaleString()} / {stats.steps.goal.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-3">
|
||||
<div
|
||||
className="bg-indigo-600 h-3 rounded-full transition-all"
|
||||
style={{ width: `${Math.min(100, getProgressPercent(stats.steps?.value ?? 0, stats.steps?.goal?.min ?? 0, stats.steps?.goal?.max ?? 0))}%` }}
|
||||
style={{ width: `${getProgressPercent(stats.steps.value, stats.steps.goal)}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Этажи */}
|
||||
<div className="mb-6">
|
||||
<div className="mb-2">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="text-gray-700 font-medium">Этажи</span>
|
||||
<span className={`font-bold ${getProgressColor(stats.floors?.value ?? 0, stats.floors?.goal?.min ?? 0, stats.floors?.goal?.max ?? 0)}`}>
|
||||
{stats.floors?.value ?? 0} / {stats.floors?.goal?.min ?? 0}-{stats.floors?.goal?.max ?? 0}
|
||||
<span className={`font-bold ${getProgressColor(stats.floors.value, stats.floors.goal)}`}>
|
||||
{stats.floors.value} / {stats.floors.goal}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-3">
|
||||
<div
|
||||
className="bg-indigo-600 h-3 rounded-full transition-all"
|
||||
style={{ width: `${Math.min(100, getProgressPercent(stats.floors?.value ?? 0, stats.floors?.goal?.min ?? 0, stats.floors?.goal?.max ?? 0))}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Баллы кардио (AZM) */}
|
||||
<div className="mb-6">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="text-gray-700 font-medium">Баллы кардио</span>
|
||||
<span className={`font-bold ${getProgressColor(stats.azm?.value ?? 0, stats.azm?.goal?.min ?? 0, stats.azm?.goal?.max ?? 0)}`}>
|
||||
{stats.azm?.value ?? 0} / {stats.azm?.goal?.min ?? 0}-{stats.azm?.goal?.max ?? 0}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-3">
|
||||
<div
|
||||
className="bg-indigo-600 h-3 rounded-full transition-all"
|
||||
style={{ width: `${Math.min(100, getProgressPercent(stats.azm?.value ?? 0, stats.azm?.goal?.min ?? 0, stats.azm?.goal?.max ?? 0))}%` }}
|
||||
style={{ width: `${getProgressPercent(stats.floors.value, stats.floors.goal)}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Настройка целей */}
|
||||
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-lg font-semibold">Дневные цели</h2>
|
||||
{!isEditingGoals && (
|
||||
<button
|
||||
onClick={() => setIsEditingGoals(true)}
|
||||
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors text-sm"
|
||||
>
|
||||
Изменить
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold mb-4">Привязка к задачам</h2>
|
||||
|
||||
{isEditingGoals ? (
|
||||
<div className="space-y-4">
|
||||
{/* Шаги */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Шаги (мин - макс)</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="number"
|
||||
value={editedGoals.steps.min}
|
||||
onChange={(e) => setEditedGoals({ ...editedGoals, steps: { ...editedGoals.steps, min: parseInt(e.target.value) || 0 } })}
|
||||
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
value={editedGoals.steps.max}
|
||||
onChange={(e) => setEditedGoals({ ...editedGoals, steps: { ...editedGoals.steps, max: parseInt(e.target.value) || 0 } })}
|
||||
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Этажи */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Этажи (мин - макс)</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="number"
|
||||
value={editedGoals.floors.min}
|
||||
onChange={(e) => setEditedGoals({ ...editedGoals, floors: { ...editedGoals.floors, min: parseInt(e.target.value) || 0 } })}
|
||||
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
value={editedGoals.floors.max}
|
||||
onChange={(e) => setEditedGoals({ ...editedGoals, floors: { ...editedGoals.floors, max: parseInt(e.target.value) || 0 } })}
|
||||
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Баллы кардио */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Баллы кардио (мин - макс)</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="number"
|
||||
value={editedGoals.azm.min}
|
||||
onChange={(e) => setEditedGoals({ ...editedGoals, azm: { ...editedGoals.azm, min: parseInt(e.target.value) || 0 } })}
|
||||
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
value={editedGoals.azm.max}
|
||||
onChange={(e) => setEditedGoals({ ...editedGoals, azm: { ...editedGoals.azm, max: parseInt(e.target.value) || 0 } })}
|
||||
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleSaveGoals}
|
||||
className="flex-1 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors"
|
||||
>
|
||||
Сохранить
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCancelEdit}
|
||||
className="flex-1 px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{loadingTasks ? (
|
||||
<div className="text-gray-500">Загрузка задач...</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Шаги:</span>
|
||||
<span className="font-medium">{goals.steps.min} - {goals.steps.max}</span>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-md font-medium text-gray-700 mb-3">Запись прогресса</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm text-gray-600 mb-1">Шаги → задача</label>
|
||||
<select
|
||||
value={editedBindings.steps_task_id ?? ''}
|
||||
onChange={(e) => setEditedBindings({
|
||||
...editedBindings,
|
||||
steps_task_id: e.target.value ? parseInt(e.target.value, 10) : null
|
||||
})}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
||||
>
|
||||
<option value="">Не выбрано</option>
|
||||
{getProgressTasks().map(task => (
|
||||
<option key={task.id} value={task.id}>{task.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-gray-600 mb-1">Этажи → задача</label>
|
||||
<select
|
||||
value={editedBindings.floors_task_id ?? ''}
|
||||
onChange={(e) => setEditedBindings({
|
||||
...editedBindings,
|
||||
floors_task_id: e.target.value ? parseInt(e.target.value, 10) : null
|
||||
})}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
||||
>
|
||||
<option value="">Не выбрано</option>
|
||||
{getProgressTasks().map(task => (
|
||||
<option key={task.id} value={task.id}>{task.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Этажи:</span>
|
||||
<span className="font-medium">{goals.floors.min} - {goals.floors.max}</span>
|
||||
|
||||
<div>
|
||||
<h3 className="text-md font-medium text-gray-700 mb-3">Отметка достижения цели по шагам</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm text-gray-600 mb-1">Задача</label>
|
||||
<select
|
||||
value={editedBindings.steps_goal_task_id ?? ''}
|
||||
onChange={(e) => setEditedBindings({
|
||||
...editedBindings,
|
||||
steps_goal_task_id: e.target.value ? parseInt(e.target.value, 10) : null,
|
||||
steps_goal_subtask_id: null
|
||||
})}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
||||
>
|
||||
<option value="">Не выбрано</option>
|
||||
{getParentTasks().map(task => (
|
||||
<option key={task.id} value={task.id}>{task.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-gray-600 mb-1">Подзадача</label>
|
||||
<select
|
||||
value={editedBindings.steps_goal_subtask_id ?? ''}
|
||||
onChange={(e) => setEditedBindings({
|
||||
...editedBindings,
|
||||
steps_goal_subtask_id: e.target.value ? parseInt(e.target.value, 10) : null
|
||||
})}
|
||||
disabled={!editedBindings.steps_goal_task_id}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg disabled:bg-gray-100"
|
||||
>
|
||||
<option value="">Не выбрано</option>
|
||||
{stepsGoalSubtasks.map(subtask => (
|
||||
<option key={subtask.id} value={subtask.id}>{subtask.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Баллы кардио:</span>
|
||||
<span className="font-medium">{goals.azm.min} - {goals.azm.max}</span>
|
||||
|
||||
<div>
|
||||
<h3 className="text-md font-medium text-gray-700 mb-3">Отметка достижения цели по этажам</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm text-gray-600 mb-1">Задача</label>
|
||||
<select
|
||||
value={editedBindings.floors_goal_task_id ?? ''}
|
||||
onChange={(e) => setEditedBindings({
|
||||
...editedBindings,
|
||||
floors_goal_task_id: e.target.value ? parseInt(e.target.value, 10) : null,
|
||||
floors_goal_subtask_id: null
|
||||
})}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
||||
>
|
||||
<option value="">Не выбрано</option>
|
||||
{getParentTasks().map(task => (
|
||||
<option key={task.id} value={task.id}>{task.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-gray-600 mb-1">Подзадача</label>
|
||||
<select
|
||||
value={editedBindings.floors_goal_subtask_id ?? ''}
|
||||
onChange={(e) => setEditedBindings({
|
||||
...editedBindings,
|
||||
floors_goal_subtask_id: e.target.value ? parseInt(e.target.value, 10) : null
|
||||
})}
|
||||
disabled={!editedBindings.floors_goal_task_id}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg disabled:bg-gray-100"
|
||||
>
|
||||
<option value="">Не выбрано</option>
|
||||
{floorsGoalSubtasks.map(subtask => (
|
||||
<option key={subtask.id} value={subtask.id}>{subtask.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleSaveBindings}
|
||||
disabled={savingBindings}
|
||||
className="w-full px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{savingBindings ? 'Сохранение...' : 'Сохранить привязки'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-6 mb-6">
|
||||
<h3 className="text-lg font-semibold mb-3 text-blue-900">
|
||||
Как это работает
|
||||
</h3>
|
||||
<p className="text-gray-700 mb-2">
|
||||
✅ Fitbit подключен! Данные синхронизируются автоматически каждые 4 часа.
|
||||
</p>
|
||||
<p className="text-gray-600 text-sm">
|
||||
Вы также можете синхронизировать данные вручную, нажав кнопку "Синхронизировать".
|
||||
</p>
|
||||
<h3 className="text-lg font-semibold mb-3 text-blue-900">Как это работает</h3>
|
||||
<ul className="text-gray-700 space-y-2 text-sm">
|
||||
<li>• Данные синхронизируются автоматически каждые 4 часа</li>
|
||||
<li>• При синхронизации данные записываются в привязанные задачи</li>
|
||||
<li>• Задачи автоматически выполняются в конце дня (23:55)</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@@ -491,7 +544,7 @@ function FitbitIntegration({ onNavigate }) {
|
||||
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
|
||||
<h2 className="text-lg font-semibold mb-4">Подключение Fitbit</h2>
|
||||
<p className="text-gray-700 mb-4">
|
||||
Подключите свой Fitbit аккаунт для отслеживания шагов, этажей и баллов кардионагрузки.
|
||||
Подключите свой Fitbit аккаунт для автоматической синхронизации шагов и этажей с вашими задачами.
|
||||
</p>
|
||||
<button
|
||||
onClick={handleConnect}
|
||||
@@ -502,18 +555,17 @@ function FitbitIntegration({ onNavigate }) {
|
||||
</div>
|
||||
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-6">
|
||||
<h3 className="text-lg font-semibold mb-3 text-blue-900">
|
||||
Что нужно сделать
|
||||
</h3>
|
||||
<h3 className="text-lg font-semibold mb-3 text-blue-900">Что нужно сделать</h3>
|
||||
<ol className="list-decimal list-inside space-y-2 text-gray-700">
|
||||
<li>Нажмите кнопку "Подключить Fitbit"</li>
|
||||
<li>Авторизуйтесь в Fitbit</li>
|
||||
<li>Разрешите доступ к данным о физической активности</li>
|
||||
<li>Готово! Данные будут синхронизироваться автоматически</li>
|
||||
<li>Настройте привязки к задачам</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{toastMessage && (
|
||||
<Toast
|
||||
message={toastMessage.text}
|
||||
|
||||
Reference in New Issue
Block a user