5.1.0: Fitbit: привязки к задачам, цели из API
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m23s

This commit is contained in:
poignatov
2026-02-09 17:06:08 +03:00
parent 29bd50acab
commit 242183a422
7 changed files with 724 additions and 349 deletions

View File

@@ -1 +1 @@
5.0.9
5.1.0

View File

@@ -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,
"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,
},
})
}

View File

@@ -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;

View File

@@ -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;

View File

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

View File

@@ -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, восстанавливаем его

View File

@@ -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>
{loading ? (
<div className="fixed inset-0 flex justify-center items-center">
<div className="flex flex-col items-center">
<div className="w-12 h-12 border-4 border-indigo-200 border-t-indigo-600 rounded-full animate-spin mb-4"></div>
<div className="text-gray-600 font-medium">Загрузка...</div>
</div>
</div>
) : connected ? (
<div>
{message && (
<div className="bg-green-50 border border-green-200 rounded-lg p-4 mb-6">
<p className="text-green-800">{message}</p>
</div>
)}
{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>
<button onClick={() => setMessage('')} className="text-green-600 text-sm underline mt-2">Скрыть</button>
</div>
)}
{/* Статистика */}
{loading ? (
<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>
<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-6">
<div>
<h3 className="text-md font-medium text-gray-700 mb-3">Запись прогресса</h3>
<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>
<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 className="flex justify-between">
<span className="text-gray-600">Этажи:</span>
<span className="font-medium">{goals.floors.min} - {goals.floors.max}</span>
<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 className="flex justify-between">
<span className="text-gray-600">Баллы кардио:</span>
<span className="font-medium">{goals.azm.min} - {goals.azm.max}</span>
</div>
</div>
<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>
<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}