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") // Публичный!
|
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/status", app.getFitbitStatusHandler).Methods("GET", "OPTIONS")
|
||||||
protected.HandleFunc("/api/integrations/fitbit/disconnect", app.fitbitDisconnectHandler).Methods("DELETE", "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/sync", app.fitbitSyncHandler).Methods("POST", "OPTIONS")
|
||||||
protected.HandleFunc("/api/integrations/fitbit/stats", app.getFitbitStatsHandler).Methods("GET", "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 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(`
|
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,
|
||||||
FROM fitbit_integrations
|
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
|
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 {
|
if err == sql.ErrNoRows || !fitbitUserID.Valid {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
@@ -10508,21 +10519,38 @@ func (a *App) getFitbitStatusHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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{}{
|
response := map[string]interface{}{
|
||||||
"connected": true,
|
"connected": true,
|
||||||
"goals": map[string]interface{}{
|
"bindings": map[string]interface{}{
|
||||||
"steps": map[string]interface{}{
|
"steps_task_id": stepsTaskIDValue,
|
||||||
"min": goalStepsMin.Int64,
|
"floors_task_id": floorsTaskIDValue,
|
||||||
"max": goalStepsMax.Int64,
|
"steps_goal_task_id": stepsGoalTaskIDValue,
|
||||||
},
|
"steps_goal_subtask_id": stepsGoalSubtaskIDValue,
|
||||||
"floors": map[string]interface{}{
|
"floors_goal_task_id": floorsGoalTaskIDValue,
|
||||||
"min": goalFloorsMin.Int64,
|
"floors_goal_subtask_id": floorsGoalSubtaskIDValue,
|
||||||
"max": goalFloorsMax.Int64,
|
|
||||||
},
|
|
||||||
"azm": map[string]interface{}{
|
|
||||||
"min": goalAzmMin.Int64,
|
|
||||||
"max": goalAzmMax.Int64,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -10564,8 +10592,8 @@ func (a *App) fitbitDisconnectHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// updateFitbitGoalsHandler обновляет цели пользователя
|
// updateFitbitBindingsHandler обновляет привязки задач для Fitbit
|
||||||
func (a *App) updateFitbitGoalsHandler(w http.ResponseWriter, r *http.Request) {
|
func (a *App) updateFitbitBindingsHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method == "OPTIONS" {
|
if r.Method == "OPTIONS" {
|
||||||
setCORSHeaders(w)
|
setCORSHeaders(w)
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
@@ -10580,9 +10608,12 @@ func (a *App) updateFitbitGoalsHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var req struct {
|
var req struct {
|
||||||
Steps map[string]int64 `json:"steps"`
|
StepsTaskID *int `json:"steps_task_id"`
|
||||||
Floors map[string]int64 `json:"floors"`
|
FloorsTaskID *int `json:"floors_task_id"`
|
||||||
Azm map[string]int64 `json:"azm"`
|
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 {
|
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
|
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(`
|
_, err := a.DB.Exec(`
|
||||||
UPDATE fitbit_integrations
|
UPDATE fitbit_integrations
|
||||||
SET goal_steps_min = $1, goal_steps_max = $2,
|
SET steps_task_id = $1,
|
||||||
goal_floors_min = $3, goal_floors_max = $4,
|
floors_task_id = $2,
|
||||||
goal_azm_min = $5, goal_azm_max = $6,
|
steps_goal_task_id = $3,
|
||||||
|
steps_goal_subtask_id = $4,
|
||||||
|
floors_goal_task_id = $5,
|
||||||
|
floors_goal_subtask_id = $6,
|
||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE user_id = $7
|
WHERE user_id = $7
|
||||||
`, req.Steps["min"], req.Steps["max"],
|
`,
|
||||||
req.Floors["min"], req.Floors["max"],
|
toNullInt64(req.StepsTaskID),
|
||||||
req.Azm["min"], req.Azm["max"],
|
toNullInt64(req.FloorsTaskID),
|
||||||
userID)
|
toNullInt64(req.StepsGoalTaskID),
|
||||||
|
toNullInt64(req.StepsGoalSubtaskID),
|
||||||
|
toNullInt64(req.FloorsGoalTaskID),
|
||||||
|
toNullInt64(req.FloorsGoalSubtaskID),
|
||||||
|
userID,
|
||||||
|
)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Fitbit update goals: DB error: %v", err)
|
log.Printf("Fitbit update bindings: DB error: %v", err)
|
||||||
sendErrorWithCORS(w, fmt.Sprintf("Failed to update goals: %v", err), http.StatusInternalServerError)
|
sendErrorWithCORS(w, fmt.Sprintf("Failed to update bindings: %v", err), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
"success": true,
|
"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)
|
return fmt.Errorf("failed to decode activity data: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Получаем Active Zone Minutes
|
// Получаем цели пользователя из Fitbit API
|
||||||
azmURL := fmt.Sprintf("https://api.fitbit.com/1/user/-/activities/active-zone-minutes/date/%s/1d.json", dateStr)
|
goalsURL := "https://api.fitbit.com/1/user/-/activities/goals/daily.json"
|
||||||
reqAZM, err := http.NewRequest("GET", azmURL, nil)
|
reqGoals, err := http.NewRequest("GET", goalsURL, nil)
|
||||||
if err != 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)
|
respGoals, err := client.Do(reqGoals)
|
||||||
reqAZM.Header.Set("Accept", "application/json")
|
|
||||||
|
|
||||||
respAZM, err := client.Do(reqAZM)
|
|
||||||
if err != nil {
|
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 goalSteps, goalFloors int
|
||||||
|
if respGoals.StatusCode == http.StatusOK {
|
||||||
var azmValue int
|
bodyBytesGoals, _ := io.ReadAll(respGoals.Body)
|
||||||
if respAZM.StatusCode == http.StatusOK {
|
var goalsData struct {
|
||||||
var azmData struct {
|
Goals struct {
|
||||||
ActivitiesActiveZoneMinutes []struct {
|
Steps int `json:"steps"`
|
||||||
Value struct {
|
Floors int `json:"floors"`
|
||||||
ActiveZoneMinutes int `json:"activeZoneMinutes"`
|
} `json:"goals"`
|
||||||
} `json:"value"`
|
|
||||||
} `json:"activities-active-zone-minutes"`
|
|
||||||
}
|
}
|
||||||
|
if err := json.Unmarshal(bodyBytesGoals, &goalsData); err == nil {
|
||||||
if err := json.Unmarshal(bodyBytesAZM, &azmData); err == nil {
|
goalSteps = goalsData.Goals.Steps
|
||||||
if len(azmData.ActivitiesActiveZoneMinutes) > 0 {
|
goalFloors = goalsData.Goals.Floors
|
||||||
azmValue = azmData.ActivitiesActiveZoneMinutes[0].Value.ActiveZoneMinutes
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if goalSteps == 0 {
|
||||||
|
goalSteps = 10000
|
||||||
|
}
|
||||||
|
if goalFloors == 0 {
|
||||||
|
goalFloors = 10
|
||||||
|
}
|
||||||
|
|
||||||
// Сохраняем данные в БД
|
// Сохраняем данные в БД
|
||||||
_, err = a.DB.Exec(`
|
_, err = a.DB.Exec(`
|
||||||
INSERT INTO fitbit_daily_stats (user_id, date, steps, floors, active_zone_minutes, updated_at)
|
INSERT INTO fitbit_daily_stats (user_id, date, steps, floors, goal_steps, goal_floors, updated_at)
|
||||||
VALUES ($1, $2, $3, $4, $5, CURRENT_TIMESTAMP)
|
VALUES ($1, $2, $3, $4, $5, $6, CURRENT_TIMESTAMP)
|
||||||
ON CONFLICT (user_id, date) DO UPDATE SET
|
ON CONFLICT (user_id, date) DO UPDATE SET
|
||||||
steps = $3,
|
steps = $3,
|
||||||
floors = $4,
|
floors = $4,
|
||||||
active_zone_minutes = $5,
|
goal_steps = $5,
|
||||||
|
goal_floors = $6,
|
||||||
updated_at = CURRENT_TIMESTAMP
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to save stats: %w", err)
|
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",
|
// Получаем привязки задач из fitbit_integrations
|
||||||
userID, dateStr, activityData.Summary.Steps, activityData.Summary.Floors, azmValue)
|
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
|
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")
|
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(`
|
err := a.DB.QueryRow(`
|
||||||
SELECT steps, floors, active_zone_minutes
|
SELECT steps, floors, goal_steps, goal_floors
|
||||||
FROM fitbit_daily_stats
|
FROM fitbit_daily_stats
|
||||||
WHERE user_id = $1 AND date = $2
|
WHERE user_id = $1 AND date = $2
|
||||||
`, userID, dateStr).Scan(&steps, &floors, &azm)
|
`, userID, dateStr).Scan(&steps, &floors, &goalSteps, &goalFloors)
|
||||||
|
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
// Данных нет, возвращаем нули
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
"date": dateStr,
|
"date": dateStr,
|
||||||
"steps": 0,
|
"steps": map[string]interface{}{
|
||||||
"floors": 0,
|
"value": 0,
|
||||||
"azm": 0,
|
"goal": 10000,
|
||||||
|
},
|
||||||
|
"floors": map[string]interface{}{
|
||||||
|
"value": 0,
|
||||||
|
"goal": 10,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -10859,22 +11122,13 @@ func (a *App) getFitbitStatsHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Получаем цели пользователя
|
goalStepsValue := goalSteps.Int64
|
||||||
var goalStepsMin, goalStepsMax, goalFloorsMin, goalFloorsMax, goalAzmMin, goalAzmMax sql.NullInt64
|
if !goalSteps.Valid || goalStepsValue == 0 {
|
||||||
err = a.DB.QueryRow(`
|
goalStepsValue = 10000
|
||||||
SELECT goal_steps_min, goal_steps_max, goal_floors_min, goal_floors_max, goal_azm_min, goal_azm_max
|
}
|
||||||
FROM fitbit_integrations
|
goalFloorsValue := goalFloors.Int64
|
||||||
WHERE user_id = $1
|
if !goalFloors.Valid || goalFloorsValue == 0 {
|
||||||
`, userID).Scan(&goalStepsMin, &goalStepsMax, &goalFloorsMin, &goalFloorsMax, &goalAzmMin, &goalAzmMax)
|
goalFloorsValue = 10
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
// Если целей нет, используем значения по умолчанию
|
|
||||||
goalStepsMin = sql.NullInt64{Int64: 8000, Valid: true}
|
|
||||||
goalStepsMax = sql.NullInt64{Int64: 10000, Valid: true}
|
|
||||||
goalFloorsMin = sql.NullInt64{Int64: 8, Valid: true}
|
|
||||||
goalFloorsMax = sql.NullInt64{Int64: 10, Valid: true}
|
|
||||||
goalAzmMin = sql.NullInt64{Int64: 22, Valid: true}
|
|
||||||
goalAzmMax = sql.NullInt64{Int64: 44, Valid: true}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
@@ -10882,24 +11136,11 @@ func (a *App) getFitbitStatsHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
"date": dateStr,
|
"date": dateStr,
|
||||||
"steps": map[string]interface{}{
|
"steps": map[string]interface{}{
|
||||||
"value": steps.Int64,
|
"value": steps.Int64,
|
||||||
"goal": map[string]interface{}{
|
"goal": goalStepsValue,
|
||||||
"min": goalStepsMin.Int64,
|
|
||||||
"max": goalStepsMax.Int64,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
"floors": map[string]interface{}{
|
"floors": map[string]interface{}{
|
||||||
"value": floors.Int64,
|
"value": floors.Int64,
|
||||||
"goal": map[string]interface{}{
|
"goal": goalFloorsValue,
|
||||||
"min": goalFloorsMin.Int64,
|
|
||||||
"max": goalFloorsMax.Int64,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"azm": map[string]interface{}{
|
|
||||||
"value": azm.Int64,
|
|
||||||
"goal": map[string]interface{}{
|
|
||||||
"min": goalAzmMin.Int64,
|
|
||||||
"max": goalAzmMax.Int64,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
"name": "play-life-web",
|
||||||
"version": "5.0.9",
|
"version": "5.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -32,6 +32,22 @@ const FULL_STATISTICS_API_URL = '/d2dc349a-0d13-49b2-a8f0-1ab094bfba9b'
|
|||||||
const mainTabs = ['current', 'tasks', 'wishlist', 'profile']
|
const mainTabs = ['current', 'tasks', 'wishlist', 'profile']
|
||||||
const deepTabs = ['add-words', 'test', 'task-form', 'wishlist-form', 'wishlist-detail', 'board-form', 'board-join', 'words', 'dictionaries', 'todoist-integration', 'telegram-integration', 'fitbit-integration', 'full', 'priorities', 'tracking', 'tracking-access', 'tracking-invite']
|
const deepTabs = ['add-words', 'test', 'task-form', 'wishlist-form', 'wishlist-detail', 'board-form', 'board-join', 'words', 'dictionaries', 'todoist-integration', 'telegram-integration', 'fitbit-integration', 'full', 'priorities', 'tracking', 'tracking-access', 'tracking-invite']
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Гарантирует базовую запись истории для главного экрана перед глубоким табом.
|
||||||
|
* После долгого бездействия 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() {
|
function AppContent() {
|
||||||
const { authFetch, isAuthenticated, loading: authLoading } = useAuth()
|
const { authFetch, isAuthenticated, loading: authLoading } = useAuth()
|
||||||
const prevIsAuthenticatedRef = useRef(null)
|
const prevIsAuthenticatedRef = useRef(null)
|
||||||
@@ -179,12 +195,12 @@ function AppContent() {
|
|||||||
if (path.startsWith('/invite/')) {
|
if (path.startsWith('/invite/')) {
|
||||||
const token = path.replace('/invite/', '')
|
const token = path.replace('/invite/', '')
|
||||||
if (token) {
|
if (token) {
|
||||||
|
const url = '/?tab=board-join&inviteToken=' + token
|
||||||
|
ensureBaseHistory('board-join', { inviteToken: token }, url)
|
||||||
setActiveTab('board-join')
|
setActiveTab('board-join')
|
||||||
setLoadedTabs(prev => ({ ...prev, 'board-join': true }))
|
setLoadedTabs(prev => ({ ...prev, 'board-join': true }))
|
||||||
setTabParams({ inviteToken: token })
|
setTabParams({ inviteToken: token })
|
||||||
setIsInitialized(true)
|
setIsInitialized(true)
|
||||||
// Очищаем путь, оставляем только параметры
|
|
||||||
window.history.replaceState({}, '', '/?tab=board-join&inviteToken=' + token)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -193,11 +209,12 @@ function AppContent() {
|
|||||||
if (path.startsWith('/tracking/invite/')) {
|
if (path.startsWith('/tracking/invite/')) {
|
||||||
const token = path.replace('/tracking/invite/', '')
|
const token = path.replace('/tracking/invite/', '')
|
||||||
if (token) {
|
if (token) {
|
||||||
|
const url = '/?tab=tracking-invite&inviteToken=' + token
|
||||||
|
ensureBaseHistory('tracking-invite', { inviteToken: token }, url)
|
||||||
setActiveTab('tracking-invite')
|
setActiveTab('tracking-invite')
|
||||||
setLoadedTabs(prev => ({ ...prev, 'tracking-invite': true }))
|
setLoadedTabs(prev => ({ ...prev, 'tracking-invite': true }))
|
||||||
setTabParams({ inviteToken: token })
|
setTabParams({ inviteToken: token })
|
||||||
setIsInitialized(true)
|
setIsInitialized(true)
|
||||||
window.history.replaceState({}, '', '/?tab=tracking-invite&inviteToken=' + token)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -206,16 +223,18 @@ function AppContent() {
|
|||||||
const urlParams = new URLSearchParams(window.location.search)
|
const urlParams = new URLSearchParams(window.location.search)
|
||||||
const integration = urlParams.get('integration')
|
const integration = urlParams.get('integration')
|
||||||
if (integration === 'fitbit') {
|
if (integration === 'fitbit') {
|
||||||
setActiveTab('fitbit-integration')
|
|
||||||
setLoadedTabs(prev => ({ ...prev, 'fitbit-integration': true }))
|
|
||||||
setIsInitialized(true)
|
|
||||||
// Перезаписываем URL с tab параметром и сохраняем integration/status для компонента
|
|
||||||
const status = urlParams.get('status')
|
const status = urlParams.get('status')
|
||||||
const message = urlParams.get('message')
|
const message = urlParams.get('message')
|
||||||
let newUrl = '/?tab=fitbit-integration&integration=fitbit'
|
let newUrl = '/?tab=fitbit-integration&integration=fitbit'
|
||||||
if (status) newUrl += `&status=${status}`
|
if (status) newUrl += `&status=${status}`
|
||||||
if (message) newUrl += `&message=${message}`
|
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
|
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']
|
const validTabs = ['current', 'priorities', 'full', 'words', 'add-words', 'dictionaries', 'test', 'tasks', 'task-form', 'wishlist', 'wishlist-form', 'wishlist-detail', 'board-form', 'board-join', 'profile', 'todoist-integration', 'telegram-integration', 'fitbit-integration', 'tracking', 'tracking-access', 'tracking-invite']
|
||||||
|
|
||||||
if (tabFromUrl && validTabs.includes(tabFromUrl) && deepTabs.includes(tabFromUrl)) {
|
if (tabFromUrl && validTabs.includes(tabFromUrl) && deepTabs.includes(tabFromUrl)) {
|
||||||
// Если в URL есть глубокий таб, восстанавливаем его
|
|
||||||
setActiveTab(tabFromUrl)
|
|
||||||
setLoadedTabs(prev => ({ ...prev, [tabFromUrl]: true }))
|
|
||||||
|
|
||||||
// Восстанавливаем параметры из URL
|
// Восстанавливаем параметры из URL
|
||||||
const params = {}
|
const params = {}
|
||||||
urlParams.forEach((value, key) => {
|
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) {
|
if (Object.keys(params).length > 0) {
|
||||||
setTabParams(params)
|
setTabParams(params)
|
||||||
// Если это экран full с selectedProject, восстанавливаем его
|
// Если это экран full с selectedProject, восстанавливаем его
|
||||||
|
|||||||
@@ -13,25 +13,32 @@ function FitbitIntegration({ onNavigate }) {
|
|||||||
const [oauthError, setOauthError] = useState('')
|
const [oauthError, setOauthError] = useState('')
|
||||||
const [toastMessage, setToastMessage] = useState(null)
|
const [toastMessage, setToastMessage] = useState(null)
|
||||||
const [isLoadingError, setIsLoadingError] = useState(false)
|
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)
|
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)
|
const oauthStatusRef = React.useRef(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Проверяем URL параметры для сообщений ДО вызова checkStatus
|
|
||||||
const params = new URLSearchParams(window.location.search)
|
const params = new URLSearchParams(window.location.search)
|
||||||
const integration = params.get('integration')
|
const integration = params.get('integration')
|
||||||
const status = params.get('status')
|
const status = params.get('status')
|
||||||
@@ -52,7 +59,6 @@ function FitbitIntegration({ onNavigate }) {
|
|||||||
}
|
}
|
||||||
setOauthError(errorMessages[errorMsg] || `Ошибка: ${errorMsg}`)
|
setOauthError(errorMessages[errorMsg] || `Ошибка: ${errorMsg}`)
|
||||||
}
|
}
|
||||||
// Очищаем URL параметры
|
|
||||||
window.history.replaceState({}, '', window.location.pathname)
|
window.history.replaceState({}, '', window.location.pathname)
|
||||||
}
|
}
|
||||||
checkStatus()
|
checkStatus()
|
||||||
@@ -61,9 +67,52 @@ function FitbitIntegration({ onNavigate }) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (connected) {
|
if (connected) {
|
||||||
loadStats()
|
loadStats()
|
||||||
|
loadTasks()
|
||||||
}
|
}
|
||||||
}, [connected])
|
}, [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 () => {
|
const checkStatus = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
@@ -74,13 +123,21 @@ function FitbitIntegration({ onNavigate }) {
|
|||||||
}
|
}
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
setConnected(data.connected || false)
|
setConnected(data.connected || false)
|
||||||
if (data.connected && data.goals) {
|
if (data.connected && data.bindings) {
|
||||||
setGoals(data.goals)
|
const b = data.bindings
|
||||||
setEditedGoals(data.goals)
|
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) {
|
if (oauthStatusRef.current === 'connected' && !data.connected) {
|
||||||
setOauthError('Авторизация в Fitbit прошла, но подключение не сохранилось. Попробуйте ещё раз или обратитесь к администратору.')
|
setOauthError('Авторизация в Fitbit прошла, но подключение не сохранилось. Попробуйте ещё раз.')
|
||||||
setMessage('')
|
setMessage('')
|
||||||
}
|
}
|
||||||
oauthStatusRef.current = null
|
oauthStatusRef.current = null
|
||||||
@@ -100,39 +157,34 @@ function FitbitIntegration({ onNavigate }) {
|
|||||||
throw new Error('Ошибка при загрузке статистики')
|
throw new Error('Ошибка при загрузке статистики')
|
||||||
}
|
}
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
// Нормализуем данные, чтобы избежать undefined
|
setStats({
|
||||||
const defaultGoal = { min: 0, max: 0 }
|
|
||||||
const normalizedStats = {
|
|
||||||
steps: {
|
steps: {
|
||||||
value: data.steps?.value ?? 0,
|
value: data.steps?.value ?? 0,
|
||||||
goal: data.steps?.goal ?? defaultGoal
|
goal: data.steps?.goal ?? 10000
|
||||||
},
|
},
|
||||||
floors: {
|
floors: {
|
||||||
value: data.floors?.value ?? 0,
|
value: data.floors?.value ?? 0,
|
||||||
goal: data.floors?.goal ?? defaultGoal
|
goal: data.floors?.goal ?? 10
|
||||||
},
|
|
||||||
azm: {
|
|
||||||
value: data.azm?.value ?? 0,
|
|
||||||
goal: data.azm?.goal ?? defaultGoal
|
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
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) {
|
} catch (error) {
|
||||||
console.error('Error loading stats:', 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?')) {
|
if (!window.confirm('Вы уверены, что хотите отключить Fitbit?')) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError('')
|
setError('')
|
||||||
@@ -175,9 +226,8 @@ function FitbitIntegration({ onNavigate }) {
|
|||||||
}
|
}
|
||||||
setConnected(false)
|
setConnected(false)
|
||||||
setStats({
|
setStats({
|
||||||
steps: { value: 0, goal: { min: 8000, max: 10000 } },
|
steps: { value: 0, goal: 10000 },
|
||||||
floors: { value: 0, goal: { min: 8, max: 10 } },
|
floors: { value: 0, goal: 10 }
|
||||||
azm: { value: 0, goal: { min: 22, max: 44 } }
|
|
||||||
})
|
})
|
||||||
setToastMessage({ text: 'Fitbit отключен', type: 'success' })
|
setToastMessage({ text: 'Fitbit отключен', type: 'success' })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -208,47 +258,44 @@ function FitbitIntegration({ onNavigate }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSaveGoals = async () => {
|
const handleSaveBindings = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await authFetch('/api/integrations/fitbit/goals', {
|
setSavingBindings(true)
|
||||||
|
const response = await authFetch('/api/integrations/fitbit/bindings', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: {
|
headers: { 'Content-Type': 'application/json' },
|
||||||
'Content-Type': 'application/json',
|
body: JSON.stringify(editedBindings)
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
steps: editedGoals.steps,
|
|
||||||
floors: editedGoals.floors,
|
|
||||||
azm: editedGoals.azm,
|
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorData = await response.json().catch(() => ({}))
|
const errorData = await response.json().catch(() => ({}))
|
||||||
throw new Error(errorData.error || 'Ошибка при сохранении целей')
|
throw new Error(errorData.error || 'Ошибка при сохранении привязок')
|
||||||
}
|
}
|
||||||
setGoals(editedGoals)
|
setBindings(editedBindings)
|
||||||
setIsEditingGoals(false)
|
setToastMessage({ text: 'Привязки сохранены', type: 'success' })
|
||||||
setToastMessage({ text: 'Цели сохранены', type: 'success' })
|
|
||||||
await loadStats()
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error saving goals:', error)
|
console.error('Error saving bindings:', error)
|
||||||
setToastMessage({ text: error.message || 'Не удалось сохранить цели', type: 'error' })
|
setToastMessage({ text: error.message || 'Не удалось сохранить привязки', type: 'error' })
|
||||||
|
} finally {
|
||||||
|
setSavingBindings(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCancelEdit = () => {
|
const getProgressTasks = () => {
|
||||||
setEditedGoals(goals)
|
return tasks.filter(t => t.has_progression || t.progression_base != null)
|
||||||
setIsEditingGoals(false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const getProgressPercent = (value, min, max) => {
|
const getParentTasks = () => {
|
||||||
if (value >= max) return 100
|
return tasks.filter(t => (t.subtasks_count ?? 0) > 0)
|
||||||
if (value <= min) return (value / min) * 50
|
|
||||||
return 50 + ((value - min) / (max - min)) * 50
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const getProgressColor = (value, min, max) => {
|
const getProgressPercent = (value, goal) => {
|
||||||
if (value >= max) return 'text-green-600'
|
if (!goal || goal === 0) return 0
|
||||||
if (value >= min) return 'text-blue-600'
|
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'
|
return 'text-gray-600'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,207 +316,213 @@ function FitbitIntegration({ onNavigate }) {
|
|||||||
✕
|
✕
|
||||||
</button>
|
</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 ? (
|
{loading ? (
|
||||||
<div className="fixed inset-0 flex justify-center items-center">
|
<div className="flex justify-center items-center h-32">
|
||||||
<div className="flex flex-col items-center">
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600"></div>
|
||||||
<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>
|
</div>
|
||||||
) : connected ? (
|
) : connected ? (
|
||||||
<div>
|
<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="bg-white rounded-lg shadow-md p-6 mb-6">
|
||||||
<div className="flex justify-between items-center mb-4">
|
<div className="flex justify-between items-center mb-4">
|
||||||
<h2 className="text-lg font-semibold">Статистика за сегодня</h2>
|
<h2 className="text-lg font-semibold">Сегодня</h2>
|
||||||
<button
|
<button
|
||||||
onClick={handleSync}
|
onClick={handleSync}
|
||||||
disabled={syncing}
|
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 ? 'Синхронизация...' : 'Синхронизировать'}
|
{syncing ? 'Синхронизация...' : 'Синхронизировать'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Шаги */}
|
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<div className="flex justify-between items-center mb-2">
|
<div className="flex justify-between items-center mb-2">
|
||||||
<span className="text-gray-700 font-medium">Шаги</span>
|
<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)}`}>
|
<span className={`font-bold ${getProgressColor(stats.steps.value, stats.steps.goal)}`}>
|
||||||
{(stats.steps?.value ?? 0).toLocaleString()} / {stats.steps?.goal?.min ?? 0}-{stats.steps?.goal?.max ?? 0}
|
{stats.steps.value.toLocaleString()} / {stats.steps.goal.toLocaleString()}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full bg-gray-200 rounded-full h-3">
|
<div className="w-full bg-gray-200 rounded-full h-3">
|
||||||
<div
|
<div
|
||||||
className="bg-indigo-600 h-3 rounded-full transition-all"
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Этажи */}
|
<div className="mb-2">
|
||||||
<div className="mb-6">
|
|
||||||
<div className="flex justify-between items-center mb-2">
|
<div className="flex justify-between items-center mb-2">
|
||||||
<span className="text-gray-700 font-medium">Этажи</span>
|
<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)}`}>
|
<span className={`font-bold ${getProgressColor(stats.floors.value, stats.floors.goal)}`}>
|
||||||
{stats.floors?.value ?? 0} / {stats.floors?.goal?.min ?? 0}-{stats.floors?.goal?.max ?? 0}
|
{stats.floors.value} / {stats.floors.goal}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full bg-gray-200 rounded-full h-3">
|
<div className="w-full bg-gray-200 rounded-full h-3">
|
||||||
<div
|
<div
|
||||||
className="bg-indigo-600 h-3 rounded-full transition-all"
|
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))}%` }}
|
style={{ width: `${getProgressPercent(stats.floors.value, stats.floors.goal)}%` }}
|
||||||
></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))}%` }}
|
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Настройка целей */}
|
|
||||||
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
|
<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 mb-4">Привязка к задачам</h2>
|
||||||
<h2 className="text-lg font-semibold">Дневные цели</h2>
|
|
||||||
{!isEditingGoals && (
|
|
||||||
<button
|
|
||||||
onClick={() => setIsEditingGoals(true)}
|
|
||||||
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors text-sm"
|
|
||||||
>
|
|
||||||
Изменить
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isEditingGoals ? (
|
{loadingTasks ? (
|
||||||
<div className="space-y-4">
|
<div className="text-gray-500">Загрузка задач...</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.steps.min}
|
|
||||||
onChange={(e) => setEditedGoals({ ...editedGoals, steps: { ...editedGoals.steps, min: parseInt(e.target.value) || 0 } })}
|
|
||||||
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={editedGoals.steps.max}
|
|
||||||
onChange={(e) => setEditedGoals({ ...editedGoals, steps: { ...editedGoals.steps, max: parseInt(e.target.value) || 0 } })}
|
|
||||||
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Этажи */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Этажи (мин - макс)</label>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={editedGoals.floors.min}
|
|
||||||
onChange={(e) => setEditedGoals({ ...editedGoals, floors: { ...editedGoals.floors, min: parseInt(e.target.value) || 0 } })}
|
|
||||||
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={editedGoals.floors.max}
|
|
||||||
onChange={(e) => setEditedGoals({ ...editedGoals, floors: { ...editedGoals.floors, max: parseInt(e.target.value) || 0 } })}
|
|
||||||
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Баллы кардио */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Баллы кардио (мин - макс)</label>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={editedGoals.azm.min}
|
|
||||||
onChange={(e) => setEditedGoals({ ...editedGoals, azm: { ...editedGoals.azm, min: parseInt(e.target.value) || 0 } })}
|
|
||||||
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={editedGoals.azm.max}
|
|
||||||
onChange={(e) => setEditedGoals({ ...editedGoals, azm: { ...editedGoals.azm, max: parseInt(e.target.value) || 0 } })}
|
|
||||||
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button
|
|
||||||
onClick={handleSaveGoals}
|
|
||||||
className="flex-1 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors"
|
|
||||||
>
|
|
||||||
Сохранить
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleCancelEdit}
|
|
||||||
className="flex-1 px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors"
|
|
||||||
>
|
|
||||||
Отмена
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-6">
|
||||||
<div className="flex justify-between">
|
<div>
|
||||||
<span className="text-gray-600">Шаги:</span>
|
<h3 className="text-md font-medium text-gray-700 mb-3">Запись прогресса</h3>
|
||||||
<span className="font-medium">{goals.steps.min} - {goals.steps.max}</span>
|
|
||||||
|
<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>
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-gray-600">Этажи:</span>
|
<div>
|
||||||
<span className="font-medium">{goals.floors.min} - {goals.floors.max}</span>
|
<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>
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-gray-600">Баллы кардио:</span>
|
<div>
|
||||||
<span className="font-medium">{goals.azm.min} - {goals.azm.max}</span>
|
<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>
|
</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>
|
</div>
|
||||||
|
|
||||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-6 mb-6">
|
<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 className="text-lg font-semibold mb-3 text-blue-900">Как это работает</h3>
|
||||||
Как это работает
|
<ul className="text-gray-700 space-y-2 text-sm">
|
||||||
</h3>
|
<li>• Данные синхронизируются автоматически каждые 4 часа</li>
|
||||||
<p className="text-gray-700 mb-2">
|
<li>• При синхронизации данные записываются в привязанные задачи</li>
|
||||||
✅ Fitbit подключен! Данные синхронизируются автоматически каждые 4 часа.
|
<li>• Задачи автоматически выполняются в конце дня (23:55)</li>
|
||||||
</p>
|
</ul>
|
||||||
<p className="text-gray-600 text-sm">
|
|
||||||
Вы также можете синхронизировать данные вручную, нажав кнопку "Синхронизировать".
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -491,7 +544,7 @@ function FitbitIntegration({ onNavigate }) {
|
|||||||
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
|
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
|
||||||
<h2 className="text-lg font-semibold mb-4">Подключение Fitbit</h2>
|
<h2 className="text-lg font-semibold mb-4">Подключение Fitbit</h2>
|
||||||
<p className="text-gray-700 mb-4">
|
<p className="text-gray-700 mb-4">
|
||||||
Подключите свой Fitbit аккаунт для отслеживания шагов, этажей и баллов кардионагрузки.
|
Подключите свой Fitbit аккаунт для автоматической синхронизации шагов и этажей с вашими задачами.
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
onClick={handleConnect}
|
onClick={handleConnect}
|
||||||
@@ -502,18 +555,17 @@ function FitbitIntegration({ onNavigate }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-6">
|
<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 className="text-lg font-semibold mb-3 text-blue-900">Что нужно сделать</h3>
|
||||||
Что нужно сделать
|
|
||||||
</h3>
|
|
||||||
<ol className="list-decimal list-inside space-y-2 text-gray-700">
|
<ol className="list-decimal list-inside space-y-2 text-gray-700">
|
||||||
<li>Нажмите кнопку "Подключить Fitbit"</li>
|
<li>Нажмите кнопку "Подключить Fitbit"</li>
|
||||||
<li>Авторизуйтесь в Fitbit</li>
|
<li>Авторизуйтесь в Fitbit</li>
|
||||||
<li>Разрешите доступ к данным о физической активности</li>
|
<li>Разрешите доступ к данным о физической активности</li>
|
||||||
<li>Готово! Данные будут синхронизироваться автоматически</li>
|
<li>Настройте привязки к задачам</li>
|
||||||
</ol>
|
</ol>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{toastMessage && (
|
{toastMessage && (
|
||||||
<Toast
|
<Toast
|
||||||
message={toastMessage.text}
|
message={toastMessage.text}
|
||||||
|
|||||||
Reference in New Issue
Block a user