diff --git a/VERSION b/VERSION index 4d0dcda..6aba2b2 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -4.1.2 +4.2.0 diff --git a/play-life-backend/main.go b/play-life-backend/main.go index 58b5bd8..99f4d9d 100644 --- a/play-life-backend/main.go +++ b/play-life-backend/main.go @@ -262,6 +262,9 @@ type TaskDetail struct { WordsCount *int `json:"words_count,omitempty"` MaxCards *int `json:"max_cards,omitempty"` DictionaryIDs []int `json:"dictionary_ids,omitempty"` + // Draft fields (only present if draft exists) + DraftProgressionValue *float64 `json:"draft_progression_value,omitempty"` + DraftSubtasks []DraftSubtask `json:"draft_subtasks,omitempty"` } type RewardRequest struct { @@ -304,6 +307,36 @@ type PostponeTaskRequest struct { NextShowAt *string `json:"next_show_at"` } +// ============================================ +// Task Draft structures +// ============================================ + +type SaveDraftRequest struct { + ProgressionValue *float64 `json:"progression_value,omitempty"` + ChildrenTaskIDs []int `json:"children_task_ids,omitempty"` // только checked подзадачи + AutoComplete bool `json:"auto_complete"` +} + +type TaskDraft struct { + ID int + TaskID int + UserID int + ProgressionValue *float64 + AutoComplete bool + CreatedAt time.Time + UpdatedAt time.Time +} + +type TaskDraftSubtask struct { + ID int + TaskDraftID int + SubtaskID int +} + +type DraftSubtask struct { + SubtaskID int `json:"subtask_id"` +} + // ============================================ // Wishlist structures // ============================================ @@ -3315,6 +3348,124 @@ func (a *App) startDailyReportScheduler() { // Планировщик будет работать в фоновом режиме } +// startEndOfDayTaskScheduler запускает планировщик для автовыполнения задач в конце дня +// каждый день в 23:55 в указанном часовом поясе +func (a *App) startEndOfDayTaskScheduler() { + // Получаем часовой пояс из переменной окружения (по умолчанию UTC) + timezoneStr := getEnv("TIMEZONE", "UTC") + log.Printf("Loading timezone for end of day task scheduler: '%s'", timezoneStr) + + // Загружаем часовой пояс + loc, err := time.LoadLocation(timezoneStr) + if err != nil { + log.Printf("Warning: Invalid timezone '%s': %v. Using UTC instead.", timezoneStr, err) + log.Printf("Note: Timezone must be in IANA format (e.g., 'Europe/Moscow', 'America/New_York'), not 'UTC+3'") + loc = time.UTC + timezoneStr = "UTC" + } else { + log.Printf("End of day task scheduler timezone set to: %s", timezoneStr) + } + + // Логируем текущее время в указанном часовом поясе для проверки + now := time.Now().In(loc) + log.Printf("Current time in scheduler timezone (%s): %s", timezoneStr, now.Format("2006-01-02 15:04:05 MST")) + log.Printf("Next end of day task execution will be at: 23:55 %s (cron: '55 23 * * *')", timezoneStr) + + // Создаем планировщик с указанным часовым поясом + c := cron.New(cron.WithLocation(loc)) + + // Добавляем задачу: каждый день в 23:55 + // Cron выражение: "55 23 * * *" означает: минута=55, час=23, любой день месяца, любой месяц, любой день недели + _, err = c.AddFunc("55 23 * * *", func() { + now := time.Now().In(loc) + log.Printf("Scheduled task: Executing end of day tasks (timezone: %s, local time: %s)", timezoneStr, now.Format("2006-01-02 15:04:05 MST")) + + // Находим все задачи с auto_complete = true + rows, err := a.DB.Query(` + SELECT task_id, user_id, progression_value + FROM task_drafts + WHERE auto_complete = TRUE + `) + if err != nil { + log.Printf("Error querying tasks for end of day execution: %v", err) + return + } + defer rows.Close() + + tasksToExecute := make([]struct { + TaskID int + UserID int + ProgressionValue *float64 + }, 0) + + for rows.Next() { + var taskID, userID int + var progressionValue sql.NullFloat64 + if err := rows.Scan(&taskID, &userID, &progressionValue); err != nil { + log.Printf("Error scanning task for end of day execution: %v", err) + continue + } + var progValue *float64 + if progressionValue.Valid { + progValue = &progressionValue.Float64 + } + tasksToExecute = append(tasksToExecute, struct { + TaskID int + UserID int + ProgressionValue *float64 + }{TaskID: taskID, UserID: userID, ProgressionValue: progValue}) + } + + // Для каждой задачи загружаем подзадачи из драфта и выполняем + for _, taskInfo := range tasksToExecute { + // Загружаем подзадачи из драфта + subtaskRows, err := a.DB.Query(` + SELECT subtask_id + FROM task_draft_subtasks + WHERE task_draft_id = (SELECT id FROM task_drafts WHERE task_id = $1) + `, taskInfo.TaskID) + + childrenTaskIDs := make([]int, 0) + if err == nil { + defer subtaskRows.Close() + for subtaskRows.Next() { + var subtaskID int + if err := subtaskRows.Scan(&subtaskID); err == nil { + childrenTaskIDs = append(childrenTaskIDs, subtaskID) + } + } + } else if err != sql.ErrNoRows { + log.Printf("Error loading subtasks for task %d: %v", taskInfo.TaskID, err) + } + + // Формируем CompleteTaskRequest из данных драфта + req := CompleteTaskRequest{ + Value: taskInfo.ProgressionValue, + ChildrenTaskIDs: childrenTaskIDs, + } + + // Вызываем executeTask - она сама удалит драфт перед выполнением + err = a.executeTask(taskInfo.TaskID, taskInfo.UserID, req) + if err != nil { + log.Printf("Error executing task %d at end of day: %v", taskInfo.TaskID, err) + } else { + log.Printf("Task %d executed successfully at end of day", taskInfo.TaskID) + } + } + }) + + if err != nil { + log.Printf("Error adding cron job for end of day tasks: %v", err) + return + } + + // Запускаем планировщик + c.Start() + log.Printf("End of day task scheduler started: every day at 23:55 %s", timezoneStr) + + // Планировщик будет работать в фоновом режиме +} + // readVersion читает версию из файла VERSION func readVersion() string { // Пробуем разные пути к файлу VERSION @@ -3456,6 +3607,9 @@ func main() { // Запускаем планировщик для ежедневного отчета в 23:59 app.startDailyReportScheduler() + // Запускаем планировщик для автовыполнения задач в конце дня в 23:55 + app.startEndOfDayTaskScheduler() + r := mux.NewRouter() // Public auth routes (no authentication required) @@ -3541,12 +3695,15 @@ func main() { // Tasks protected.HandleFunc("/api/tasks", app.getTasksHandler).Methods("GET", "OPTIONS") protected.HandleFunc("/api/tasks", app.createTaskHandler).Methods("POST", "OPTIONS") - protected.HandleFunc("/api/tasks/{id}", app.getTaskDetailHandler).Methods("GET", "OPTIONS") - protected.HandleFunc("/api/tasks/{id}", app.updateTaskHandler).Methods("PUT", "OPTIONS") - protected.HandleFunc("/api/tasks/{id}", app.deleteTaskHandler).Methods("DELETE", "OPTIONS") + // Специфичные роуты должны быть ПЕРЕД общим роутом /api/tasks/{id} protected.HandleFunc("/api/tasks/{id}/complete", app.completeTaskHandler).Methods("POST", "OPTIONS") protected.HandleFunc("/api/tasks/{id}/complete-and-delete", app.completeAndDeleteTaskHandler).Methods("POST", "OPTIONS") protected.HandleFunc("/api/tasks/{id}/postpone", app.postponeTaskHandler).Methods("POST", "OPTIONS") + protected.HandleFunc("/api/tasks/{id}/draft", app.saveTaskDraftHandler).Methods("PUT", "OPTIONS") + protected.HandleFunc("/api/tasks/{id}/complete-at-end-of-day", app.completeTaskAtEndOfDayHandler).Methods("POST", "OPTIONS") + protected.HandleFunc("/api/tasks/{id}", app.getTaskDetailHandler).Methods("GET", "OPTIONS") + protected.HandleFunc("/api/tasks/{id}", app.updateTaskHandler).Methods("PUT", "OPTIONS") + protected.HandleFunc("/api/tasks/{id}", app.deleteTaskHandler).Methods("DELETE", "OPTIONS") // Wishlist protected.HandleFunc("/api/wishlist", app.getWishlistHandler).Methods("GET", "OPTIONS") @@ -6769,6 +6926,56 @@ func (a *App) getTaskDetailHandler(w http.ResponseWriter, r *http.Request) { } } + // Загружаем данные из драфта, если он существует + var draftProgressionValue sql.NullFloat64 + err = a.DB.QueryRow(` + SELECT progression_value + FROM task_drafts + WHERE task_id = $1 AND user_id = $2 + `, taskID, userID).Scan(&draftProgressionValue) + + if err == nil { + // Драфт существует, загружаем данные + if draftProgressionValue.Valid { + response.DraftProgressionValue = &draftProgressionValue.Float64 + } + + // Загружаем подзадачи из драфта + draftSubtaskRows, err := a.DB.Query(` + SELECT subtask_id + FROM task_draft_subtasks + WHERE task_draft_id = (SELECT id FROM task_drafts WHERE task_id = $1 AND user_id = $2) + `, taskID, userID) + if err == nil { + defer draftSubtaskRows.Close() + draftSubtasks := make([]DraftSubtask, 0) + validSubtaskIDs := make(map[int]bool) + // Создаем map валидных подзадач для фильтрации + for _, subtask := range subtasks { + validSubtaskIDs[subtask.Task.ID] = true + } + + for draftSubtaskRows.Next() { + var subtaskID int + if err := draftSubtaskRows.Scan(&subtaskID); err == nil { + // Игнорируем подзадачи, которых больше нет в основной задаче + if validSubtaskIDs[subtaskID] { + draftSubtasks = append(draftSubtasks, DraftSubtask{ + SubtaskID: subtaskID, + }) + } + } + } + if len(draftSubtasks) > 0 { + response.DraftSubtasks = draftSubtasks + } + } else if err != sql.ErrNoRows { + log.Printf("Error loading draft subtasks for task %d: %v", taskID, err) + } + } else if err != sql.ErrNoRows { + log.Printf("Error loading draft for task %d: %v", taskID, err) + } + // Если задача - тест (есть config_id), загружаем данные конфигурации if configID.Valid { var wordsCount int @@ -7815,6 +8022,171 @@ func (a *App) updateTaskHandler(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(updatedTask) } +// saveTaskDraftHandler сохраняет или обновляет драфт задачи +func (a *App) saveTaskDraftHandler(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 + } + + vars := mux.Vars(r) + taskID, err := strconv.Atoi(vars["id"]) + if err != nil { + sendErrorWithCORS(w, "Invalid task ID", http.StatusBadRequest) + return + } + + // Проверяем владельца задачи + var ownerID int + err = a.DB.QueryRow("SELECT user_id FROM tasks WHERE id = $1 AND deleted = FALSE", taskID).Scan(&ownerID) + if err == sql.ErrNoRows || ownerID != userID { + sendErrorWithCORS(w, "Task not found", http.StatusNotFound) + return + } + if err != nil { + log.Printf("Error checking task ownership: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Error checking task ownership: %v", err), http.StatusInternalServerError) + return + } + + var req SaveDraftRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + log.Printf("Error decoding save draft request: %v", err) + sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest) + return + } + + // Начинаем транзакцию + tx, err := a.DB.Begin() + if err != nil { + log.Printf("Error beginning transaction: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Error beginning transaction: %v", err), http.StatusInternalServerError) + return + } + defer tx.Rollback() + + // Проверяем, существует ли драфт + var draftID int + err = tx.QueryRow("SELECT id FROM task_drafts WHERE task_id = $1", taskID).Scan(&draftID) + + var progressionValue sql.NullFloat64 + if req.ProgressionValue != nil { + progressionValue = sql.NullFloat64{Float64: *req.ProgressionValue, Valid: true} + } + + if err == sql.ErrNoRows { + // Создаем новый драфт + err = tx.QueryRow(` + INSERT INTO task_drafts (task_id, user_id, progression_value, auto_complete, created_at, updated_at) + VALUES ($1, $2, $3, $4, NOW(), NOW()) + RETURNING id + `, taskID, userID, progressionValue, req.AutoComplete).Scan(&draftID) + + if err != nil { + log.Printf("Error creating draft: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Error creating draft: %v", err), http.StatusInternalServerError) + return + } + } else if err != nil { + log.Printf("Error checking draft existence: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Error checking draft existence: %v", err), http.StatusInternalServerError) + return + } else { + // Обновляем существующий драфт + // При обновлении очищаем auto_complete если параметр false + autoComplete := req.AutoComplete + _, err = tx.Exec(` + UPDATE task_drafts + SET progression_value = $1, auto_complete = $2, updated_at = NOW() + WHERE id = $3 + `, progressionValue, autoComplete, draftID) + + if err != nil { + log.Printf("Error updating draft: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Error updating draft: %v", err), http.StatusInternalServerError) + return + } + + // Удаляем все старые записи подзадач + _, err = tx.Exec("DELETE FROM task_draft_subtasks WHERE task_draft_id = $1", draftID) + if err != nil { + log.Printf("Error deleting old draft subtasks: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Error deleting old draft subtasks: %v", err), http.StatusInternalServerError) + return + } + } + + // Вставляем новые записи подзадач (только checked подзадачи) + if len(req.ChildrenTaskIDs) > 0 { + // Проверяем, что все подзадачи принадлежат этой задаче + placeholders := make([]string, len(req.ChildrenTaskIDs)) + args := make([]interface{}, len(req.ChildrenTaskIDs)+1) + args[0] = taskID + for i, id := range req.ChildrenTaskIDs { + placeholders[i] = fmt.Sprintf("$%d", i+2) + args[i+1] = id + } + + query := fmt.Sprintf(` + SELECT id FROM tasks + WHERE parent_task_id = $1 AND id IN (%s) AND deleted = FALSE + `, strings.Join(placeholders, ",")) + + validSubtaskRows, err := tx.Query(query, args...) + if err != nil { + log.Printf("Error validating subtasks: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Error validating subtasks: %v", err), http.StatusInternalServerError) + return + } + defer validSubtaskRows.Close() + + validSubtaskIDs := make(map[int]bool) + for validSubtaskRows.Next() { + var id int + if err := validSubtaskRows.Scan(&id); err == nil { + validSubtaskIDs[id] = true + } + } + + // Вставляем только валидные подзадачи + for _, subtaskID := range req.ChildrenTaskIDs { + if validSubtaskIDs[subtaskID] { + _, 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) + if err != nil { + log.Printf("Error inserting draft subtask: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Error inserting draft subtask: %v", err), http.StatusInternalServerError) + return + } + } + } + } + + // Коммитим транзакцию + if err = tx.Commit(); err != nil { + log.Printf("Error committing transaction: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Error committing transaction: %v", err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "message": "Draft saved successfully", + }) +} + // deleteTaskHandler удаляет задачу (помечает как deleted) func (a *App) deleteTaskHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { @@ -7865,33 +8237,14 @@ func (a *App) deleteTaskHandler(w http.ResponseWriter, r *http.Request) { }) } -// completeTaskHandler выполняет задачу -func (a *App) completeTaskHandler(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 - } - - vars := mux.Vars(r) - taskID, err := strconv.Atoi(vars["id"]) +// executeTask выполняет задачу (вынесенная логика) +// Удаляет драфт перед выполнением и выполняет всю логику выполнения задачи +func (a *App) executeTask(taskID int, userID int, req CompleteTaskRequest) error { + // Удаляем драфт перед выполнением (если есть) + _, err := a.DB.Exec(`DELETE FROM task_drafts WHERE task_id = $1`, taskID) if err != nil { - sendErrorWithCORS(w, "Invalid task ID", http.StatusBadRequest) - return - } - - var req CompleteTaskRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - log.Printf("Error decoding complete task request: %v", err) - sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest) - return + log.Printf("Error deleting draft for task %d: %v", taskID, err) + // Не возвращаем ошибку, продолжаем выполнение } // Получаем задачу и проверяем владельца @@ -7910,18 +8263,15 @@ func (a *App) completeTaskHandler(w http.ResponseWriter, r *http.Request) { `, taskID).Scan(&task.ID, &task.Name, &rewardMessage, &progressionBase, &repetitionPeriod, &repetitionDate, &ownerID, &wishlistID) if err == sql.ErrNoRows { - sendErrorWithCORS(w, "Task not found", http.StatusNotFound) - return + return fmt.Errorf("task not found") } if err != nil { log.Printf("Error querying task: %v", err) - sendErrorWithCORS(w, fmt.Sprintf("Error querying task: %v", err), http.StatusInternalServerError) - return + return fmt.Errorf("error querying task: %v", err) } if ownerID != userID { - sendErrorWithCORS(w, "Task not found", http.StatusNotFound) - return + return fmt.Errorf("task not found") } // Проверяем, что желание разблокировано (если задача связана с желанием) @@ -7929,19 +8279,16 @@ func (a *App) completeTaskHandler(w http.ResponseWriter, r *http.Request) { unlocked, err := a.checkWishlistUnlock(int(wishlistID.Int64), userID) if err != nil { log.Printf("Error checking wishlist unlock status: %v", err) - sendErrorWithCORS(w, fmt.Sprintf("Error checking wishlist unlock status: %v", err), http.StatusInternalServerError) - return + return fmt.Errorf("error checking wishlist unlock status: %v", err) } if !unlocked { - sendErrorWithCORS(w, "Cannot complete task: wishlist item is not unlocked", http.StatusBadRequest) - return + return fmt.Errorf("cannot complete task: wishlist item is not unlocked") } } // Валидация: если progression_base != null, то value обязателен if progressionBase.Valid && req.Value == nil { - sendErrorWithCORS(w, "Value is required when progression_base is set", http.StatusBadRequest) - return + return fmt.Errorf("value is required when progression_base is set") } if rewardMessage.Valid { @@ -7962,8 +8309,7 @@ func (a *App) completeTaskHandler(w http.ResponseWriter, r *http.Request) { if err != nil { log.Printf("Error querying rewards: %v", err) - sendErrorWithCORS(w, fmt.Sprintf("Error querying rewards: %v", err), http.StatusInternalServerError) - return + return fmt.Errorf("error querying rewards: %v", err) } defer rewardRows.Close() @@ -8259,8 +8605,7 @@ func (a *App) completeTaskHandler(w http.ResponseWriter, r *http.Request) { if err != nil { log.Printf("Error updating task completion: %v", err) - sendErrorWithCORS(w, fmt.Sprintf("Error updating task completion: %v", err), http.StatusInternalServerError) - return + return fmt.Errorf("error updating task completion: %v", err) } // Обновляем выбранные подзадачи @@ -8286,7 +8631,6 @@ func (a *App) completeTaskHandler(w http.ResponseWriter, r *http.Request) { } // Если задача связана с желанием, завершаем желание и обрабатываем политику награждения - // Используем wishlistID из начала функции (уже объявлена) if wishlistID.Valid { // Завершаем желание _, completeErr := a.DB.Exec(` @@ -8304,6 +8648,221 @@ func (a *App) completeTaskHandler(w http.ResponseWriter, r *http.Request) { } } + return nil +} + +// completeTaskAtEndOfDayHandler устанавливает автовыполнение задачи в конце дня +func (a *App) completeTaskAtEndOfDayHandler(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 + } + + vars := mux.Vars(r) + taskID, err := strconv.Atoi(vars["id"]) + if err != nil { + sendErrorWithCORS(w, "Invalid task ID", http.StatusBadRequest) + return + } + + // Проверяем владельца задачи + var ownerID int + err = a.DB.QueryRow("SELECT user_id FROM tasks WHERE id = $1 AND deleted = FALSE", taskID).Scan(&ownerID) + if err == sql.ErrNoRows || ownerID != userID { + sendErrorWithCORS(w, "Task not found", http.StatusNotFound) + return + } + if err != nil { + log.Printf("Error checking task ownership: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Error checking task ownership: %v", err), http.StatusInternalServerError) + return + } + + var req SaveDraftRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + log.Printf("Error decoding save draft request: %v", err) + sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest) + return + } + + // Устанавливаем auto_complete = true + req.AutoComplete = true + + // Используем ту же логику что и saveTaskDraftHandler + // Начинаем транзакцию + tx, err := a.DB.Begin() + if err != nil { + log.Printf("Error beginning transaction: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Error beginning transaction: %v", err), http.StatusInternalServerError) + return + } + defer tx.Rollback() + + // Проверяем, существует ли драфт + var draftID int + err = tx.QueryRow("SELECT id FROM task_drafts WHERE task_id = $1", taskID).Scan(&draftID) + + var progressionValue sql.NullFloat64 + if req.ProgressionValue != nil { + progressionValue = sql.NullFloat64{Float64: *req.ProgressionValue, Valid: true} + } + + if err == sql.ErrNoRows { + // Создаем новый драфт + err = tx.QueryRow(` + INSERT INTO task_drafts (task_id, user_id, progression_value, auto_complete, created_at, updated_at) + VALUES ($1, $2, $3, $4, NOW(), NOW()) + RETURNING id + `, taskID, userID, progressionValue, req.AutoComplete).Scan(&draftID) + + if err != nil { + log.Printf("Error creating draft: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Error creating draft: %v", err), http.StatusInternalServerError) + return + } + } else if err != nil { + log.Printf("Error checking draft existence: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Error checking draft existence: %v", err), http.StatusInternalServerError) + return + } else { + // Обновляем существующий драфт с auto_complete = true + _, err = tx.Exec(` + UPDATE task_drafts + SET progression_value = $1, auto_complete = $2, updated_at = NOW() + WHERE id = $3 + `, progressionValue, req.AutoComplete, draftID) + + if err != nil { + log.Printf("Error updating draft: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Error updating draft: %v", err), http.StatusInternalServerError) + return + } + + // Удаляем все старые записи подзадач + _, err = tx.Exec("DELETE FROM task_draft_subtasks WHERE task_draft_id = $1", draftID) + if err != nil { + log.Printf("Error deleting old draft subtasks: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Error deleting old draft subtasks: %v", err), http.StatusInternalServerError) + return + } + } + + // Вставляем новые записи подзадач (только checked подзадачи) + if len(req.ChildrenTaskIDs) > 0 { + // Проверяем, что все подзадачи принадлежат этой задаче + placeholders := make([]string, len(req.ChildrenTaskIDs)) + args := make([]interface{}, len(req.ChildrenTaskIDs)+1) + args[0] = taskID + for i, id := range req.ChildrenTaskIDs { + placeholders[i] = fmt.Sprintf("$%d", i+2) + args[i+1] = id + } + + query := fmt.Sprintf(` + SELECT id FROM tasks + WHERE parent_task_id = $1 AND id IN (%s) AND deleted = FALSE + `, strings.Join(placeholders, ",")) + + validSubtaskRows, err := tx.Query(query, args...) + if err != nil { + log.Printf("Error validating subtasks: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Error validating subtasks: %v", err), http.StatusInternalServerError) + return + } + defer validSubtaskRows.Close() + + validSubtaskIDs := make(map[int]bool) + for validSubtaskRows.Next() { + var id int + if err := validSubtaskRows.Scan(&id); err == nil { + validSubtaskIDs[id] = true + } + } + + // Вставляем только валидные подзадачи + for _, subtaskID := range req.ChildrenTaskIDs { + if validSubtaskIDs[subtaskID] { + _, 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) + if err != nil { + log.Printf("Error inserting draft subtask: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Error inserting draft subtask: %v", err), http.StatusInternalServerError) + return + } + } + } + } + + // Коммитим транзакцию + if err = tx.Commit(); err != nil { + log.Printf("Error committing transaction: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Error committing transaction: %v", err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "message": "Task will be completed at end of day", + }) +} + +// completeTaskHandler выполняет задачу +func (a *App) completeTaskHandler(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 + } + + vars := mux.Vars(r) + taskID, err := strconv.Atoi(vars["id"]) + if err != nil { + sendErrorWithCORS(w, "Invalid task ID", http.StatusBadRequest) + return + } + + var req CompleteTaskRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + log.Printf("Error decoding complete task request: %v", err) + sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest) + return + } + + // Используем executeTask для выполнения задачи + err = a.executeTask(taskID, userID, req) + if err != nil { + if strings.Contains(err.Error(), "not found") { + sendErrorWithCORS(w, err.Error(), http.StatusNotFound) + } else if strings.Contains(err.Error(), "unlocked") { + sendErrorWithCORS(w, err.Error(), http.StatusBadRequest) + } else if strings.Contains(err.Error(), "required") { + sendErrorWithCORS(w, err.Error(), http.StatusBadRequest) + } else { + log.Printf("Error executing task: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Error executing task: %v", err), http.StatusInternalServerError) + } + return + } + w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "success": true, @@ -8333,8 +8892,7 @@ func (a *App) completeAndDeleteTaskHandler(w http.ResponseWriter, r *http.Reques return } - // Сначала выполняем задачу (используем ту же логику, что и в completeTaskHandler) - // Создаем временный запрос для выполнения задачи + // Сначала выполняем задачу используя executeTask var req CompleteTaskRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { log.Printf("Error decoding complete task request: %v", err) @@ -8342,298 +8900,20 @@ func (a *App) completeAndDeleteTaskHandler(w http.ResponseWriter, r *http.Reques return } - // Получаем задачу и проверяем владельца - var task Task - var rewardMessage sql.NullString - var progressionBase sql.NullFloat64 - var repetitionPeriod sql.NullString - var repetitionDate sql.NullString - var ownerID int - var wishlistID sql.NullInt64 - - err = a.DB.QueryRow(` - SELECT id, name, reward_message, progression_base, repetition_period::text, repetition_date, user_id, wishlist_id - FROM tasks - WHERE id = $1 AND deleted = FALSE - `, taskID).Scan(&task.ID, &task.Name, &rewardMessage, &progressionBase, &repetitionPeriod, &repetitionDate, &ownerID, &wishlistID) - - if err == sql.ErrNoRows { - sendErrorWithCORS(w, "Task not found", http.StatusNotFound) - return - } + // Используем executeTask для выполнения задачи + err = a.executeTask(taskID, userID, req) if err != nil { - log.Printf("Error querying task: %v", err) - sendErrorWithCORS(w, fmt.Sprintf("Error querying task: %v", err), http.StatusInternalServerError) - return - } - - if ownerID != userID { - sendErrorWithCORS(w, "Task not found", http.StatusNotFound) - return - } - - // Проверяем, что желание разблокировано (если задача связана с желанием) - if wishlistID.Valid { - unlocked, err := a.checkWishlistUnlock(int(wishlistID.Int64), userID) - if err != nil { - log.Printf("Error checking wishlist unlock status: %v", err) - sendErrorWithCORS(w, fmt.Sprintf("Error checking wishlist unlock status: %v", err), http.StatusInternalServerError) - return - } - if !unlocked { - sendErrorWithCORS(w, "Cannot complete task: wishlist item is not unlocked", http.StatusBadRequest) - return - } - } - - // Валидация: если progression_base != null, то value обязателен - if progressionBase.Valid && req.Value == nil { - sendErrorWithCORS(w, "Value is required when progression_base is set", http.StatusBadRequest) - return - } - - if rewardMessage.Valid { - task.RewardMessage = &rewardMessage.String - } - if progressionBase.Valid { - task.ProgressionBase = &progressionBase.Float64 - } - - // Получаем награды основной задачи - rewardRows, err := a.DB.Query(` - SELECT rc.position, p.name AS project_name, rc.value, rc.use_progression - FROM reward_configs rc - JOIN projects p ON rc.project_id = p.id - WHERE rc.task_id = $1 - ORDER BY rc.position - `, taskID) - - if err != nil { - log.Printf("Error querying rewards: %v", err) - sendErrorWithCORS(w, fmt.Sprintf("Error querying rewards: %v", err), http.StatusInternalServerError) - return - } - defer rewardRows.Close() - - rewards := make([]Reward, 0) - for rewardRows.Next() { - var reward Reward - err := rewardRows.Scan(&reward.Position, &reward.ProjectName, &reward.Value, &reward.UseProgression) - if err != nil { - log.Printf("Error scanning reward: %v", err) - continue - } - rewards = append(rewards, reward) - } - - // Вычисляем score для каждой награды и формируем строки для подстановки - rewardStrings := make(map[int]string) - for _, reward := range rewards { - var score float64 - if reward.UseProgression && progressionBase.Valid && req.Value != nil { - score = (*req.Value / progressionBase.Float64) * reward.Value + if strings.Contains(err.Error(), "not found") { + sendErrorWithCORS(w, err.Error(), http.StatusNotFound) + } else if strings.Contains(err.Error(), "unlocked") { + sendErrorWithCORS(w, err.Error(), http.StatusBadRequest) + } else if strings.Contains(err.Error(), "required") { + sendErrorWithCORS(w, err.Error(), http.StatusBadRequest) } else { - score = reward.Value - } - - var rewardStr string - if score >= 0 { - rewardStr = fmt.Sprintf("**%s+%.4g**", reward.ProjectName, score) - } else { - rewardStr = fmt.Sprintf("**%s-%.4g**", reward.ProjectName, math.Abs(score)) - } - rewardStrings[reward.Position] = rewardStr - } - - // Функция для замены плейсхолдеров в сообщении награды - replaceRewardPlaceholders := func(message string, rewardStrings map[int]string) string { - result := message - escapedMarkers := make(map[string]string) - for i := 0; i < 100; i++ { - escaped := fmt.Sprintf(`\$%d`, i) - marker := fmt.Sprintf(`__ESCAPED_DOLLAR_%d__`, i) - if strings.Contains(result, escaped) { - escapedMarkers[marker] = escaped - result = strings.ReplaceAll(result, escaped, marker) - } - } - for i := 0; i < 100; i++ { - placeholder := fmt.Sprintf("${%d}", i) - if rewardStr, ok := rewardStrings[i]; ok { - result = strings.ReplaceAll(result, placeholder, rewardStr) - } - } - for i := 99; i >= 0; i-- { - if rewardStr, ok := rewardStrings[i]; ok { - searchStr := fmt.Sprintf("$%d", i) - for { - idx := strings.LastIndex(result, searchStr) - if idx == -1 { - break - } - afterIdx := idx + len(searchStr) - if afterIdx >= len(result) || result[afterIdx] < '0' || result[afterIdx] > '9' { - result = result[:idx] + rewardStr + result[afterIdx:] - } else { - break - } - } - } - } - for marker, escaped := range escapedMarkers { - result = strings.ReplaceAll(result, marker, escaped) - } - return result - } - - // Подставляем в reward_message основной задачи - var mainTaskMessage string - if task.RewardMessage != nil && *task.RewardMessage != "" { - mainTaskMessage = replaceRewardPlaceholders(*task.RewardMessage, rewardStrings) - } else { - mainTaskMessage = task.Name - } - - // Получаем выбранные подзадачи - subtaskMessages := make([]string, 0) - if len(req.ChildrenTaskIDs) > 0 { - placeholders := make([]string, len(req.ChildrenTaskIDs)) - args := make([]interface{}, len(req.ChildrenTaskIDs)+1) - args[0] = taskID - for i, id := range req.ChildrenTaskIDs { - placeholders[i] = fmt.Sprintf("$%d", i+2) - args[i+1] = id - } - - query := fmt.Sprintf(` - SELECT id, name, reward_message, progression_base - FROM tasks - WHERE parent_task_id = $1 AND id IN (%s) AND deleted = FALSE - `, strings.Join(placeholders, ",")) - - subtaskRows, err := a.DB.Query(query, args...) - if err != nil { - log.Printf("Error querying subtasks: %v", err) - } else { - defer subtaskRows.Close() - for subtaskRows.Next() { - var subtaskID int - var subtaskName string - var subtaskRewardMessage sql.NullString - var subtaskProgressionBase sql.NullFloat64 - - err := subtaskRows.Scan(&subtaskID, &subtaskName, &subtaskRewardMessage, &subtaskProgressionBase) - if err != nil { - log.Printf("Error scanning subtask: %v", err) - continue - } - - if !subtaskRewardMessage.Valid || subtaskRewardMessage.String == "" { - continue - } - - subtaskRewardRows, err := a.DB.Query(` - SELECT rc.position, p.name AS project_name, rc.value, rc.use_progression - FROM reward_configs rc - JOIN projects p ON rc.project_id = p.id - WHERE rc.task_id = $1 - ORDER BY rc.position - `, subtaskID) - - if err != nil { - log.Printf("Error querying subtask rewards: %v", err) - continue - } - - subtaskRewards := make([]Reward, 0) - for subtaskRewardRows.Next() { - var reward Reward - err := subtaskRewardRows.Scan(&reward.Position, &reward.ProjectName, &reward.Value, &reward.UseProgression) - if err != nil { - log.Printf("Error scanning subtask reward: %v", err) - continue - } - subtaskRewards = append(subtaskRewards, reward) - } - subtaskRewardRows.Close() - - subtaskRewardStrings := make(map[int]string) - for _, reward := range subtaskRewards { - var score float64 - if reward.UseProgression && subtaskProgressionBase.Valid && req.Value != nil { - score = (*req.Value / subtaskProgressionBase.Float64) * reward.Value - } else if reward.UseProgression && progressionBase.Valid && req.Value != nil { - score = (*req.Value / progressionBase.Float64) * reward.Value - } else { - score = reward.Value - } - - var rewardStr string - if score >= 0 { - rewardStr = fmt.Sprintf("**%s+%.4g**", reward.ProjectName, score) - } else { - rewardStr = fmt.Sprintf("**%s-%.4g**", reward.ProjectName, math.Abs(score)) - } - subtaskRewardStrings[reward.Position] = rewardStr - } - - subtaskMessage := replaceRewardPlaceholders(subtaskRewardMessage.String, subtaskRewardStrings) - subtaskMessages = append(subtaskMessages, subtaskMessage) - } - } - } - - // Формируем итоговое сообщение - var finalMessage strings.Builder - finalMessage.WriteString(mainTaskMessage) - for _, subtaskMsg := range subtaskMessages { - finalMessage.WriteString("\n + ") - finalMessage.WriteString(subtaskMsg) - } - - // Отправляем сообщение через processMessage - userIDPtr := &userID - _, err = a.processMessage(finalMessage.String(), userIDPtr) - if err != nil { - log.Printf("Error sending message to Telegram: %v", err) - } - - // Обновляем выбранные подзадачи - if len(req.ChildrenTaskIDs) > 0 { - placeholders := make([]string, len(req.ChildrenTaskIDs)) - args := make([]interface{}, len(req.ChildrenTaskIDs)) - for i, id := range req.ChildrenTaskIDs { - placeholders[i] = fmt.Sprintf("$%d", i+1) - args[i] = id - } - - query := fmt.Sprintf(` - UPDATE tasks - SET completed = completed + 1, last_completed_at = NOW() - WHERE id IN (%s) AND deleted = FALSE - `, strings.Join(placeholders, ",")) - - _, err = a.DB.Exec(query, args...) - if err != nil { - log.Printf("Error updating subtasks completion: %v", err) - } - } - - // Если задача связана с желанием, завершаем желание (до удаления задачи) - // Используем wishlistID из начала функции (уже объявлена) - if wishlistID.Valid { - // Завершаем желание - _, completeErr := a.DB.Exec(` - UPDATE wishlist_items - SET completed = TRUE - WHERE id = $1 AND user_id = $2 AND completed = FALSE - `, wishlistID.Int64, userID) - if completeErr != nil { - log.Printf("Error completing wishlist item %d: %v", wishlistID.Int64, completeErr) - // Не возвращаем ошибку, задача уже выполнена - } else { - log.Printf("Wishlist item %d completed automatically after task %d completion", wishlistID.Int64, taskID) + log.Printf("Error executing task: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Error executing task: %v", err), http.StatusInternalServerError) } + return } // Помечаем задачу как удаленную diff --git a/play-life-backend/migrations/000005_add_task_drafts.down.sql b/play-life-backend/migrations/000005_add_task_drafts.down.sql new file mode 100644 index 0000000..59837f6 --- /dev/null +++ b/play-life-backend/migrations/000005_add_task_drafts.down.sql @@ -0,0 +1,7 @@ +-- Migration: Remove task drafts tables +-- Date: 2026-01-26 +-- +-- This migration removes tables created for task drafts + +DROP TABLE IF EXISTS task_draft_subtasks; +DROP TABLE IF EXISTS task_drafts; diff --git a/play-life-backend/migrations/000005_add_task_drafts.up.sql b/play-life-backend/migrations/000005_add_task_drafts.up.sql new file mode 100644 index 0000000..e818a06 --- /dev/null +++ b/play-life-backend/migrations/000005_add_task_drafts.up.sql @@ -0,0 +1,45 @@ +-- Migration: Add task drafts tables +-- Date: 2026-01-26 +-- +-- This migration creates tables for storing task drafts: +-- 1. task_drafts - main table for task drafts with progression value and auto_complete flag +-- 2. task_draft_subtasks - stores only checked subtask IDs for each draft + +-- ============================================ +-- Table: task_drafts +-- ============================================ +CREATE TABLE task_drafts ( + id SERIAL PRIMARY KEY, + task_id INTEGER REFERENCES tasks(id) ON DELETE CASCADE, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + progression_value NUMERIC(10,4), + auto_complete BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + UNIQUE(task_id) +); + +CREATE INDEX idx_task_drafts_task_id ON task_drafts(task_id); +CREATE INDEX idx_task_drafts_user_id ON task_drafts(user_id); +CREATE INDEX idx_task_drafts_auto_complete ON task_drafts(auto_complete) WHERE auto_complete = TRUE; + +COMMENT ON TABLE task_drafts IS 'Stores draft states for tasks with progression value and auto-complete flag'; +COMMENT ON COLUMN task_drafts.progression_value IS 'Saved progression value from user input'; +COMMENT ON COLUMN task_drafts.auto_complete IS 'Flag indicating task should be auto-completed at end of day (23:55)'; +COMMENT ON COLUMN task_drafts.task_id IS 'Reference to task. UNIQUE constraint ensures one draft per task'; + +-- ============================================ +-- Table: task_draft_subtasks +-- ============================================ +CREATE TABLE task_draft_subtasks ( + id SERIAL PRIMARY KEY, + task_draft_id INTEGER REFERENCES task_drafts(id) ON DELETE CASCADE, + subtask_id INTEGER REFERENCES tasks(id) ON DELETE CASCADE, + UNIQUE(task_draft_id, subtask_id) +); + +CREATE INDEX idx_task_draft_subtasks_task_draft_id ON task_draft_subtasks(task_draft_id); +CREATE INDEX idx_task_draft_subtasks_subtask_id ON task_draft_subtasks(subtask_id); + +COMMENT ON TABLE task_draft_subtasks IS 'Stores only checked subtask IDs for each draft. If subtask is not in this table, it means it is unchecked'; +COMMENT ON COLUMN task_draft_subtasks.subtask_id IS 'Reference to subtask task. Only checked subtasks are stored here'; diff --git a/play-life-backend/play-eng-backend b/play-life-backend/play-eng-backend new file mode 100755 index 0000000..fbfc1ea Binary files /dev/null and b/play-life-backend/play-eng-backend differ diff --git a/play-life-web/package.json b/play-life-web/package.json index d0bc853..a2f0730 100644 --- a/play-life-web/package.json +++ b/play-life-web/package.json @@ -1,6 +1,6 @@ { "name": "play-life-web", - "version": "4.1.2", + "version": "4.2.0", "type": "module", "scripts": { "dev": "vite", diff --git a/play-life-web/src/components/TaskDetail.css b/play-life-web/src/components/TaskDetail.css index be636d2..e0ef83d 100644 --- a/play-life-web/src/components/TaskDetail.css +++ b/play-life-web/src/components/TaskDetail.css @@ -317,3 +317,77 @@ text-decoration: none; } +/* Dropdown styles */ +.dropdown-container { + position: relative; + display: inline-block; +} + +.dropdown-button { + padding: 0; + background: transparent; + color: #6366f1; + border: 2px solid #6366f1; + border-radius: 0.375rem; + font-size: 1rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; + display: flex; + align-items: center; + justify-content: center; + width: calc(0.75rem * 2 + 1.2rem + 4px); + height: calc(0.75rem * 2 + 1.2rem + 4px); + box-sizing: border-box; +} + +.dropdown-button:hover:not(:disabled) { + transform: translateY(-1px); + background: rgba(99, 102, 241, 0.1); + box-shadow: 0 4px 12px rgba(99, 102, 241, 0.2); +} + +.dropdown-button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.dropdown-menu { + position: fixed; + background: white; + border: 1px solid #e5e7eb; + border-radius: 0.5rem; + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15); + min-width: 240px; + z-index: 1800; + overflow: hidden; +} + +.dropdown-item { + width: 100%; + padding: 0.5rem 1rem; + background: none; + border: none; + text-align: left; + font-size: 0.95rem; + color: #374151; + cursor: pointer; + transition: background-color 0.2s; + display: flex; + align-items: center; + border-bottom: 1px solid #f3f4f6; +} + +.dropdown-item:last-child { + border-bottom: none; +} + +.dropdown-item:hover:not(:disabled) { + background-color: #f9fafb; + color: #1f2937; +} + +.dropdown-item:disabled { + opacity: 0.5; + cursor: not-allowed; +} diff --git a/play-life-web/src/components/TaskDetail.css.bak b/play-life-web/src/components/TaskDetail.css.bak new file mode 100644 index 0000000..be636d2 --- /dev/null +++ b/play-life-web/src/components/TaskDetail.css.bak @@ -0,0 +1,319 @@ +/* Модальное окно */ +.task-detail-modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1700; + padding: 1rem; +} + +.task-detail-modal { + background: white; + border-radius: 0.5rem; + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2); + max-width: 400px; + width: 100%; + max-height: 90vh; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.task-detail-modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1rem 1.5rem; +} + +.task-detail-close-button { + background: none; + border: none; + font-size: 1.5rem; + color: #6b7280; + cursor: pointer; + padding: 0; + width: 2rem; + height: 2rem; + display: flex; + align-items: center; + justify-content: center; + border-radius: 0.25rem; + transition: all 0.2s; +} + +.task-detail-close-button:hover { + background: #f3f4f6; + color: #1f2937; +} + +.task-detail-modal-content { + padding: 0 1.5rem 1.5rem 1.5rem; + overflow-y: auto; + flex: 1; +} + +.task-detail-title { + font-size: 1.25rem; + font-weight: 600; + color: #1f2937; + margin: 0; +} + +.task-reward-message { + margin-bottom: 2rem; + padding: 1rem; + background: #f9fafb; + border-radius: 0.375rem; + border-left: 3px solid #6366f1; +} + +.reward-message-text { + color: #374151; + line-height: 1.6; +} + +.reward-message-text strong { + color: #1f2937; + font-weight: 600; +} + +.task-subtasks { + margin-bottom: 1rem; +} + +.subtasks-title { + font-size: 1rem; + font-weight: 600; + color: #1f2937; + margin: 0 0 1rem 0; +} + +.subtask-item { + margin-bottom: 0.5rem; +} + +.subtask-checkbox-label { + display: flex; + align-items: center; + gap: 0.75rem; + cursor: pointer; +} + +.subtask-checkbox { + flex-shrink: 0; + width: 1.25rem; + height: 1.25rem; + cursor: pointer; +} + +.subtask-content { + flex: 1; +} + +.subtask-name { + font-weight: 500; + color: #1f2937; +} + +.subtask-reward-message { + margin-top: 0.5rem; + padding: 0.75rem; + background: white; + border-radius: 0.25rem; +} + +.progression-section { + margin-bottom: 1.5rem; +} + +.progression-label { + display: block; + font-size: 0.875rem; + font-weight: 600; + color: #374151; + margin-bottom: 0.5rem; +} + +.progression-input { + width: 100%; + padding: 0.75rem; + border: 1px solid #d1d5db; + border-radius: 0.375rem; + font-size: 1rem; + box-sizing: border-box; +} + +.progression-input:focus { + outline: none; + border-color: #6366f1; + box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1); +} + +.task-detail-divider { + height: 1px; + background: #e5e7eb; + margin: 1.5rem 0; +} + +.telegram-message-preview { + margin-bottom: 1.5rem; + padding: 1rem; + background: #f9fafb; + border-radius: 0.375rem; + border-left: 3px solid #6366f1; +} + +.telegram-message-label { + font-size: 0.875rem; + font-weight: 600; + color: #374151; + margin-bottom: 0.5rem; +} + +.telegram-message-text { + color: #1f2937; + line-height: 1.6; + white-space: pre-wrap; +} + +.telegram-message-text strong { + font-weight: 600; + color: #1f2937; +} + +.task-actions-section { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.task-actions-buttons { + display: flex; + gap: 0.75rem; + align-items: center; +} + +.complete-button { + flex: 1; + padding: 0.75rem 1.5rem; + background: linear-gradient(to right, #6366f1, #8b5cf6); + color: white; + border: none; + border-radius: 0.375rem; + font-size: 1rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; + display: flex; + align-items: center; + justify-content: center; +} + +.complete-button:hover:not(:disabled) { + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3); +} + +.complete-button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.close-button-outline { + padding: 0.75rem; + background: transparent; + color: #6366f1; + border: 2px solid #6366f1; + border-radius: 0.375rem; + font-size: 1rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; + display: flex; + align-items: center; + justify-content: center; + min-width: 2.75rem; + height: 2.75rem; +} + +.close-button-outline:hover:not(:disabled) { + transform: translateY(-1px); + background: rgba(99, 102, 241, 0.1); + box-shadow: 0 4px 12px rgba(99, 102, 241, 0.2); +} + +.close-button-outline:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.next-task-date-info { + font-size: 0.875rem; + color: #6b7280; + text-align: left; + margin-top: -0.125rem; + margin-bottom: -0.5rem; +} + +.loading, +.error-message { + text-align: center; + padding: 3rem 1rem; + color: #6b7280; +} + +.error-message { + color: #ef4444; +} + +.task-wishlist-link { + margin-bottom: 1.5rem; + padding: 0.75rem; + background-color: #f0f9ff; + border-radius: 6px; + border: 1px solid #bae6fd; +} + +.task-wishlist-link-info { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.task-wishlist-link-info svg { + color: #6366f1; + flex-shrink: 0; +} + +.task-wishlist-link-label { + font-size: 0.9rem; + color: #374151; + font-weight: 500; +} + +.task-wishlist-link-button { + background: none; + border: none; + color: #6366f1; + font-size: 0.95rem; + font-weight: 500; + cursor: pointer; + padding: 0.25rem 0.5rem; + border-radius: 4px; + transition: all 0.2s; + text-decoration: underline; + margin-left: auto; +} + +.task-wishlist-link-button:hover { + background-color: rgba(99, 102, 241, 0.1); + text-decoration: none; +} + diff --git a/play-life-web/src/components/TaskDetail.jsx b/play-life-web/src/components/TaskDetail.jsx index 407e653..e9d5ee1 100644 --- a/play-life-web/src/components/TaskDetail.jsx +++ b/play-life-web/src/components/TaskDetail.jsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useCallback, useMemo } from 'react' +import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react' import { useAuth } from './auth/AuthContext' import LoadingError from './LoadingError' import Toast from './Toast' @@ -331,20 +331,10 @@ const formatTelegramMessage = (task, rewards, subtasks, selectedSubtasks, progre // Формируем сообщения подзадач const subtaskMessages = [] - // #region agent log - fetch('http://127.0.0.1:7243/ingest/dd59cdcd-2e10-41ef-b65f-ebbaae0d7424',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'TaskDetail.jsx:333',message:'Starting subtask messages processing',data:{subtasksCount:subtasks.length,selectedSubtasksCount:selectedSubtasks.size,selectedSubtasks:Array.from(selectedSubtasks)},timestamp:Date.now(),sessionId:'debug-session',runId:'run2',hypothesisId:'B'})}).catch(()=>{}); - // #endregion subtasks.forEach(subtask => { - // #region agent log - fetch('http://127.0.0.1:7243/ingest/dd59cdcd-2e10-41ef-b65f-ebbaae0d7424',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'TaskDetail.jsx:336',message:'Checking subtask',data:{subtaskId:subtask.task.id,isSelected:selectedSubtasks.has(subtask.task.id),hasRewardMessage:!!(subtask.task.reward_message && subtask.task.reward_message.trim() !== ''),rewardsCount:subtask.rewards?.length||0},timestamp:Date.now(),sessionId:'debug-session',runId:'run2',hypothesisId:'B'})}).catch(()=>{}); - // #endregion if (!selectedSubtasks.has(subtask.task.id)) return if (!subtask.task.reward_message || subtask.task.reward_message.trim() === '') return - // #region agent log - fetch('http://127.0.0.1:7243/ingest/dd59cdcd-2e10-41ef-b65f-ebbaae0d7424',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'TaskDetail.jsx:340',message:'Processing subtask for message',data:{subtaskId:subtask.task.id,subtaskName:subtask.task.name,rewardsCount:subtask.rewards?.length||0,rewards:subtask.rewards,rewardMessage:subtask.task.reward_message},timestamp:Date.now(),sessionId:'debug-session',runId:'run2',hypothesisId:'B'})}).catch(()=>{}); - // #endregion - // Вычисляем score для наград подзадачи const subtaskRewardStrings = {} subtask.rewards.forEach(reward => { @@ -393,6 +383,10 @@ function TaskDetail({ taskId, onClose, onRefresh, onTaskCompleted, onNavigate }) const [isCompleting, setIsCompleting] = useState(false) const [toastMessage, setToastMessage] = useState(null) const [wishlistInfo, setWishlistInfo] = useState(null) + const [showDropdown, setShowDropdown] = useState(false) + const [isSaving, setIsSaving] = useState(false) + const [dropdownPosition, setDropdownPosition] = useState({ top: 0, right: 0 }) + const dropdownButtonRef = useRef(null) const fetchTaskDetail = useCallback(async () => { try { @@ -403,9 +397,6 @@ function TaskDetail({ taskId, onClose, onRefresh, onTaskCompleted, onNavigate }) throw new Error('Ошибка загрузки задачи') } const data = await response.json() - // #region agent log - fetch('http://127.0.0.1:7243/ingest/dd59cdcd-2e10-41ef-b65f-ebbaae0d7424',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'TaskDetail.jsx:395',message:'TaskDetail data loaded',data:{taskId:taskId,subtasksCount:data.subtasks?.length||0,subtasks:data.subtasks?.map(st=>({id:st.task?.id,name:st.task?.name,rewardsCount:st.rewards?.length||0,rewards:st.rewards}))},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'A'})}).catch(()=>{}); - // #endregion setTaskDetail(data) // Используем информацию о wishlist из ответа API @@ -418,6 +409,26 @@ function TaskDetail({ taskId, onClose, onRefresh, onTaskCompleted, onNavigate }) } else { setWishlistInfo(null) } + + // Предзаполнение данных из драфта + if (data.draft_progression_value != null) { + setProgressionValue(data.draft_progression_value.toString()) + } + + if (data.draft_subtasks && data.draft_subtasks.length > 0) { + // Создаем Set из ID подзадач из драфта + const draftSubtaskIDs = new Set(data.draft_subtasks.map(ds => ds.subtask_id)) + // Фильтруем только те подзадачи, которые существуют в текущих подзадачах задачи + const validSubtaskIDs = new Set() + if (data.subtasks) { + data.subtasks.forEach(subtask => { + if (draftSubtaskIDs.has(subtask.task.id)) { + validSubtaskIDs.add(subtask.task.id) + } + }) + } + setSelectedSubtasks(validSubtaskIDs) + } } catch (err) { setError(err.message) console.error('Error fetching task detail:', err) @@ -451,6 +462,83 @@ function TaskDetail({ taskId, onClose, onRefresh, onTaskCompleted, onNavigate }) }) } + const handleSaveDraft = async (autoComplete = false) => { + if (!taskDetail) return + + setIsSaving(true) + try { + const payload = { + auto_complete: autoComplete, + children_task_ids: Array.from(selectedSubtasks) + } + + // Если есть прогрессия, отправляем значение (или progression_base, если не введено) + if (taskDetail.task.progression_base != null) { + if (progressionValue.trim()) { + const parsedValue = parseFloat(progressionValue) + if (isNaN(parsedValue)) { + throw new Error('Неверное значение') + } + payload.progression_value = parsedValue + } else { + // Если прогрессия не введена - используем progression_base + payload.progression_value = taskDetail.task.progression_base + } + } else { + // Если нет progression_base, но пользователь ввел значение - отправляем его + if (progressionValue.trim()) { + const parsedValue = parseFloat(progressionValue) + if (!isNaN(parsedValue)) { + payload.progression_value = parsedValue + } + } + } + + const endpoint = autoComplete + ? `${API_URL}/${taskId}/complete-at-end-of-day` + : `${API_URL}/${taskId}/draft` + + const response = await authFetch(endpoint, { + method: autoComplete ? 'POST' : 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + }) + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + throw new Error(errorData.message || 'Ошибка при сохранении драфта') + } + + setToastMessage({ + text: autoComplete + ? 'Задача будет выполнена в конце дня' + : 'Драфт сохранен', + type: 'success' + }) + + // Обновляем данные задачи + if (onRefresh) { + onRefresh() + } + + // Закрываем модальное окно после успешного сохранения + if (onClose) { + onClose() + } + } catch (err) { + console.error('Error saving draft:', err) + setToastMessage({ text: err.message || 'Ошибка при сохранении драфта', type: 'error' }) + } finally { + setIsSaving(false) + } + } + + const handleCompleteAtEndOfDay = async () => { + await handleSaveDraft(true) + } + const handleComplete = async (shouldDelete = false) => { if (!taskDetail) return @@ -564,8 +652,27 @@ function TaskDetail({ taskId, onClose, onRefresh, onTaskCompleted, onNavigate }) return formatTelegramMessage(task, rewards || [], subtasks || [], selectedSubtasks, progressionValue) }, [taskDetail, task, rewards, subtasks, selectedSubtasks, progressionValue]) + + // Закрываем dropdown при клике вне его + useEffect(() => { + const handleClickOutside = (event) => { + if (showDropdown && !event.target.closest('.dropdown-container') && !event.target.closest('.dropdown-menu')) { + setShowDropdown(false) + } + } + document.addEventListener('mousedown', handleClickOutside) + return () => { + document.removeEventListener('mousedown', handleClickOutside) + } + }, [showDropdown]) + return ( -