diff --git a/VERSION b/VERSION index 831446c..ac14c3d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -5.1.0 +5.1.1 diff --git a/play-life-backend/main.go b/play-life-backend/main.go index 21afc97..24707b3 100644 --- a/play-life-backend/main.go +++ b/play-life-backend/main.go @@ -3859,6 +3859,44 @@ func (a *App) startFitbitSyncScheduler() { // Планировщик будет работать в фоновом режиме } +// startFitbitDailySyncScheduler запускает обязательную синхронизацию Fitbit каждый день в 23:50 (часовой пояс из TIMEZONE) +func (a *App) startFitbitDailySyncScheduler() { + timezoneStr := getEnv("TIMEZONE", "UTC") + log.Printf("Loading timezone for Fitbit daily sync scheduler: '%s'", timezoneStr) + + loc, err := time.LoadLocation(timezoneStr) + if err != nil { + log.Printf("Warning: Invalid timezone '%s': %v. Using UTC instead.", timezoneStr, err) + loc = time.UTC + timezoneStr = "UTC" + } else { + log.Printf("Fitbit daily sync scheduler timezone set to: %s", timezoneStr) + } + + now := time.Now().In(loc) + log.Printf("Current time in Fitbit daily sync timezone (%s): %s", timezoneStr, now.Format("2006-01-02 15:04:05 MST")) + log.Printf("Next mandatory Fitbit sync will be at: 23:50 %s (cron: '50 23 * * *')", timezoneStr) + + c := cron.New(cron.WithLocation(loc)) + _, err = c.AddFunc("50 23 * * *", func() { + now := time.Now().In(loc) + log.Printf("Scheduled task: Mandatory Fitbit sync at 23:50 (timezone: %s, local time: %s)", timezoneStr, now.Format("2006-01-02 15:04:05 MST")) + if err := a.syncFitbitDataForAllUsers(); err != nil { + log.Printf("Error in mandatory Fitbit sync at 23:50: %v", err) + } else { + log.Printf("Mandatory Fitbit sync at 23:50 completed successfully") + } + }) + + if err != nil { + log.Printf("Error adding cron job for Fitbit daily sync at 23:50: %v", err) + return + } + + c.Start() + log.Printf("Fitbit daily sync scheduler started: every day at 23:50 %s", timezoneStr) +} + // syncFitbitDataForAllUsers синхронизирует данные Fitbit для всех подключенных пользователей func (a *App) syncFitbitDataForAllUsers() error { rows, err := a.DB.Query(` @@ -4170,6 +4208,8 @@ func main() { // Запускаем планировщик синхронизации Fitbit каждые 4 часа app.startFitbitSyncScheduler() + // Обязательная синхронизация Fitbit каждый день в 23:50 (перед автовыполнением задач в 23:55) + app.startFitbitDailySyncScheduler() r := mux.NewRouter() @@ -4272,6 +4312,8 @@ func main() { 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/bindings", app.updateFitbitBindingsHandler).Methods("PUT", "OPTIONS") + protected.HandleFunc("/api/integrations/fitbit/bindings/steps", app.updateFitbitStepsBindingsHandler).Methods("PUT", "OPTIONS") + protected.HandleFunc("/api/integrations/fitbit/bindings/floors", app.updateFitbitFloorsBindingsHandler).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") @@ -10717,6 +10759,180 @@ func (a *App) updateFitbitBindingsHandler(w http.ResponseWriter, r *http.Request }) } +// updateFitbitStepsBindingsHandler обновляет только привязки для шагов +func (a *App) updateFitbitStepsBindingsHandler(w http.ResponseWriter, r *http.Request) { + if r.Method == "OPTIONS" { + setCORSHeaders(w) + w.WriteHeader(http.StatusOK) + return + } + setCORSHeaders(w) + + userID, ok := getUserIDFromContext(r) + if !ok { + sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) + return + } + + var req struct { + StepsTaskID *int `json:"steps_task_id"` + StepsGoalTaskID *int `json:"steps_goal_task_id"` + StepsGoalSubtaskID *int `json:"steps_goal_subtask_id"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest) + 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 not found", fieldName) + } + 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 not found", fieldName) + } + return nil + } + if err := validateTask(req.StepsTaskID, "steps_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 := validateSubtask(req.StepsGoalSubtaskID, req.StepsGoalTaskID, "steps_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 steps_task_id = $1, steps_goal_task_id = $2, steps_goal_subtask_id = $3, updated_at = CURRENT_TIMESTAMP + WHERE user_id = $4 + `, toNullInt64(req.StepsTaskID), toNullInt64(req.StepsGoalTaskID), toNullInt64(req.StepsGoalSubtaskID), userID) + if err != nil { + log.Printf("Fitbit update steps bindings: %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": "Steps bindings updated"}) +} + +// updateFitbitFloorsBindingsHandler обновляет только привязки для этажей +func (a *App) updateFitbitFloorsBindingsHandler(w http.ResponseWriter, r *http.Request) { + if r.Method == "OPTIONS" { + setCORSHeaders(w) + w.WriteHeader(http.StatusOK) + return + } + setCORSHeaders(w) + + userID, ok := getUserIDFromContext(r) + if !ok { + sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) + return + } + + var req struct { + FloorsTaskID *int `json:"floors_task_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 { + sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest) + 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 not found", fieldName) + } + 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 not found", fieldName) + } + return nil + } + if err := validateTask(req.FloorsTaskID, "floors_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.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 floors_task_id = $1, floors_goal_task_id = $2, floors_goal_subtask_id = $3, updated_at = CURRENT_TIMESTAMP + WHERE user_id = $4 + `, toNullInt64(req.FloorsTaskID), toNullInt64(req.FloorsGoalTaskID), toNullInt64(req.FloorsGoalSubtaskID), userID) + if err != nil { + log.Printf("Fitbit update floors bindings: %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": "Floors bindings updated"}) +} + // getFitbitAccessToken получает актуальный access_token (обновляет если нужно) func (a *App) getFitbitAccessToken(userID int) (string, error) { var accessToken, refreshToken sql.NullString diff --git a/play-life-web/package.json b/play-life-web/package.json index f9b6e9c..46be506 100644 --- a/play-life-web/package.json +++ b/play-life-web/package.json @@ -1,6 +1,6 @@ { "name": "play-life-web", - "version": "5.1.0", + "version": "5.1.1", "type": "module", "scripts": { "dev": "vite", diff --git a/play-life-web/src/components/FitbitIntegration.jsx b/play-life-web/src/components/FitbitIntegration.jsx index 5cd06c8..7e98f8a 100644 --- a/play-life-web/src/components/FitbitIntegration.jsx +++ b/play-life-web/src/components/FitbitIntegration.jsx @@ -29,7 +29,8 @@ function FitbitIntegration({ onNavigate }) { floors_goal_subtask_id: null }) const [editedBindings, setEditedBindings] = useState(bindings) - const [savingBindings, setSavingBindings] = useState(false) + const [savingStepsBindings, setSavingStepsBindings] = useState(false) + const [savingFloorsBindings, setSavingFloorsBindings] = useState(false) const [tasks, setTasks] = useState([]) const [loadingTasks, setLoadingTasks] = useState(false) @@ -258,25 +259,65 @@ function FitbitIntegration({ onNavigate }) { } } - const handleSaveBindings = async () => { + const handleSaveStepsBindings = async () => { try { - setSavingBindings(true) - const response = await authFetch('/api/integrations/fitbit/bindings', { + setSavingStepsBindings(true) + const response = await authFetch('/api/integrations/fitbit/bindings/steps', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(editedBindings) + body: JSON.stringify({ + steps_task_id: editedBindings.steps_task_id, + steps_goal_task_id: editedBindings.steps_goal_task_id, + steps_goal_subtask_id: editedBindings.steps_goal_subtask_id + }) }) if (!response.ok) { const errorData = await response.json().catch(() => ({})) throw new Error(errorData.error || 'Ошибка при сохранении привязок') } - setBindings(editedBindings) - setToastMessage({ text: 'Привязки сохранены', type: 'success' }) + setBindings(prev => ({ + ...prev, + steps_task_id: editedBindings.steps_task_id, + steps_goal_task_id: editedBindings.steps_goal_task_id, + steps_goal_subtask_id: editedBindings.steps_goal_subtask_id + })) + setToastMessage({ text: 'Привязки шагов сохранены', type: 'success' }) } catch (error) { - console.error('Error saving bindings:', error) + console.error('Error saving steps bindings:', error) setToastMessage({ text: error.message || 'Не удалось сохранить привязки', type: 'error' }) } finally { - setSavingBindings(false) + setSavingStepsBindings(false) + } + } + + const handleSaveFloorsBindings = async () => { + try { + setSavingFloorsBindings(true) + const response = await authFetch('/api/integrations/fitbit/bindings/floors', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + floors_task_id: editedBindings.floors_task_id, + floors_goal_task_id: editedBindings.floors_goal_task_id, + floors_goal_subtask_id: editedBindings.floors_goal_subtask_id + }) + }) + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + throw new Error(errorData.error || 'Ошибка при сохранении привязок') + } + setBindings(prev => ({ + ...prev, + floors_task_id: editedBindings.floors_task_id, + floors_goal_task_id: editedBindings.floors_goal_task_id, + floors_goal_subtask_id: editedBindings.floors_goal_subtask_id + })) + setToastMessage({ text: 'Привязки этажей сохранены', type: 'success' }) + } catch (error) { + console.error('Error saving floors bindings:', error) + setToastMessage({ text: error.message || 'Не удалось сохранить привязки', type: 'error' }) + } finally { + setSavingFloorsBindings(false) } } @@ -299,6 +340,16 @@ function FitbitIntegration({ onNavigate }) { return 'text-gray-600' } + const isStepsBindingsDirty = + (editedBindings.steps_task_id ?? null) !== (bindings.steps_task_id ?? null) || + (editedBindings.steps_goal_task_id ?? null) !== (bindings.steps_goal_task_id ?? null) || + (editedBindings.steps_goal_subtask_id ?? null) !== (bindings.steps_goal_subtask_id ?? null) + + const isFloorsBindingsDirty = + (editedBindings.floors_task_id ?? null) !== (bindings.floors_task_id ?? null) || + (editedBindings.floors_goal_task_id ?? null) !== (bindings.floors_goal_task_id ?? null) || + (editedBindings.floors_goal_subtask_id ?? null) !== (bindings.floors_goal_subtask_id ?? null) + if (isLoadingError && !loading) { return (
@@ -331,118 +382,67 @@ function FitbitIntegration({ onNavigate }) {
) : connected ? (
+ {/* Группа: Шаги */}
-
-

Сегодня

- -
+

Шаги

-
-
- Шаги - - {stats.steps.value.toLocaleString()} / {stats.steps.goal.toLocaleString()} - -
-
-
-
-
- -
-
- Этажи - - {stats.floors.value} / {stats.floors.goal} - -
-
-
-
-
-
- -
-

Привязка к задачам

- - {loadingTasks ? ( -
Загрузка задач...
- ) : ( -
-
-

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

- -
-
- - -
- -
- - -
-
+
+
+
+ Сегодня + + {stats.steps.value.toLocaleString()} / {stats.steps.goal.toLocaleString()} +
+
+
+
+
-
-

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

- -
-
- - -
+
+ +

Задача

+ +
+
+

Достижение цели

+
+
+ + +
+ {editedBindings.steps_goal_task_id && (
-
+ )}
+
-
-

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

+ {isStepsBindingsDirty && ( + + )} +
+
-
-
- - -
+ {/* Группа: Этажи */} +
+

Этажи

+
+
+
+ Сегодня + + {stats.floors.value} / {stats.floors.goal} + +
+
+
+
+
+ +
+ +

Задача

+ +
+ +
+

Достижение цели

+
+
+ + +
+ {editedBindings.floors_goal_task_id && (
-
+ )}
- -
- )} + + {isFloorsBindingsDirty && ( + + )} +
+ +

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