diff --git a/VERSION b/VERSION index 3cf5751..40c341b 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -3.5.7 +3.6.0 diff --git a/play-life-backend/main.go b/play-life-backend/main.go index d8550e5..c821a68 100644 --- a/play-life-backend/main.go +++ b/play-life-backend/main.go @@ -3791,6 +3791,7 @@ func main() { protected.HandleFunc("/api/tasks/{id}", app.updateTaskHandler).Methods("PUT", "OPTIONS") protected.HandleFunc("/api/tasks/{id}", app.deleteTaskHandler).Methods("DELETE", "OPTIONS") 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") // Admin operations @@ -7843,6 +7844,314 @@ func (a *App) completeTaskHandler(w http.ResponseWriter, r *http.Request) { }) } +// completeAndDeleteTaskHandler выполняет задачу и затем удаляет её +func (a *App) completeAndDeleteTaskHandler(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 + } + + // Сначала выполняем задачу (используем ту же логику, что и в completeTaskHandler) + // Создаем временный запрос для выполнения задачи + 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 + } + + // Получаем задачу и проверяем владельца + var task Task + var rewardMessage sql.NullString + var progressionBase sql.NullFloat64 + var repetitionPeriod sql.NullString + var repetitionDate sql.NullString + var ownerID int + + err = a.DB.QueryRow(` + SELECT id, name, reward_message, progression_base, repetition_period, repetition_date, user_id + FROM tasks + WHERE id = $1 AND deleted = FALSE + `, taskID).Scan(&task.ID, &task.Name, &rewardMessage, &progressionBase, &repetitionPeriod, &repetitionDate, &ownerID) + + if err == sql.ErrNoRows { + sendErrorWithCORS(w, "Task not found", http.StatusNotFound) + return + } + 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 + } + + // Валидация: если 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 + } 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) + } + } + + // Помечаем задачу как удаленную + _, err = a.DB.Exec("UPDATE tasks SET deleted = TRUE WHERE id = $1 AND user_id = $2", taskID, userID) + if err != nil { + log.Printf("Error deleting task: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Error deleting task: %v", err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "message": "Task completed and deleted successfully", + }) +} + // postponeTaskHandler переносит задачу на указанную дату func (a *App) postponeTaskHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { diff --git a/play-life-web/package.json b/play-life-web/package.json index 37a9ba8..9249577 100644 --- a/play-life-web/package.json +++ b/play-life-web/package.json @@ -1,6 +1,6 @@ { "name": "play-life-web", - "version": "3.5.7", + "version": "3.6.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 2fd03b6..2396d80 100644 --- a/play-life-web/src/components/TaskDetail.css +++ b/play-life-web/src/components/TaskDetail.css @@ -129,22 +129,25 @@ border-radius: 0.25rem; } -.task-complete-section { - margin-top: 1rem; +.progression-section { + margin-bottom: 1.5rem; } -.progression-input-group { - display: flex; - gap: 0.5rem; - align-items: center; +.progression-label { + display: block; + font-size: 0.875rem; + font-weight: 600; + color: #374151; + margin-bottom: 0.5rem; } .progression-input { - flex: 1; + width: 100%; padding: 0.75rem; border: 1px solid #d1d5db; border-radius: 0.375rem; font-size: 1rem; + box-sizing: border-box; } .progression-input:focus { @@ -153,7 +156,46 @@ 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; + gap: 0.75rem; + align-items: center; +} + .complete-button { + flex: 1; padding: 0.75rem 1.5rem; background: linear-gradient(to right, #6366f1, #8b5cf6); color: white; @@ -163,11 +205,9 @@ font-weight: 500; cursor: pointer; transition: all 0.2s; - min-width: 3rem; -} - -.complete-button.full-width { - width: 100%; + display: flex; + align-items: center; + justify-content: center; } .complete-button:hover:not(:disabled) { @@ -180,6 +220,34 @@ 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; +} + .loading, .error-message { text-align: center; diff --git a/play-life-web/src/components/TaskDetail.jsx b/play-life-web/src/components/TaskDetail.jsx index 083d87b..6b37542 100644 --- a/play-life-web/src/components/TaskDetail.jsx +++ b/play-life-web/src/components/TaskDetail.jsx @@ -1,9 +1,168 @@ -import React, { useState, useEffect, useCallback } from 'react' +import React, { useState, useEffect, useCallback, useMemo } from 'react' import { useAuth } from './auth/AuthContext' import './TaskDetail.css' const API_URL = '/api/tasks' +// Функция для проверки, является ли период нулевым +const isZeroPeriod = (intervalStr) => { + if (!intervalStr) return false + const trimmed = intervalStr.trim() + const parts = trimmed.split(/\s+/) + if (parts.length < 1) return false + const value = parseInt(parts[0], 10) + return !isNaN(value) && value === 0 +} + +// Функция для проверки, является ли repetition_date нулевым +const isZeroDate = (dateStr) => { + if (!dateStr) return false + const trimmed = dateStr.trim() + const parts = trimmed.split(/\s+/) + if (parts.length < 2) return false + const value = parts[0] + const numValue = parseInt(value, 10) + return !isNaN(numValue) && numValue === 0 +} + +// Функция для форматирования числа как %.4g в Go (до 4 значащих цифр) +const formatScore = (num) => { + if (num === 0) return '0' + + // Используем toPrecision(4) для получения до 4 значащих цифр + let str = num.toPrecision(4) + + // Убираем лишние нули в конце (но оставляем точку если есть цифры после неё) + str = str.replace(/\.?0+$/, '') + + // Если получилась экспоненциальная нотация для больших чисел, конвертируем обратно + if (str.includes('e+') || str.includes('e-')) { + const numValue = parseFloat(str) + // Для чисел >= 10000 используем экспоненциальную нотацию + if (Math.abs(numValue) >= 10000) { + return str + } + // Для остальных конвертируем в обычное число + return numValue.toString().replace(/\.?0+$/, '') + } + + return str +} + +// Функция для формирования сообщения Telegram в реальном времени +const formatTelegramMessage = (task, rewards, subtasks, selectedSubtasks, progressionValue) => { + if (!task) return '' + + // Вычисляем score для каждой награды основной задачи + const rewardStrings = {} + const progressionBase = task.progression_base + const hasProgression = progressionBase != null + // Если прогрессия не введена - используем progression_base + const value = progressionValue && progressionValue.trim() !== '' + ? parseFloat(progressionValue) + : (hasProgression ? progressionBase : null) + + rewards.forEach(reward => { + let score = reward.value + if (reward.use_progression && hasProgression) { + if (value !== null && !isNaN(value)) { + score = (value / progressionBase) * reward.value + } else { + // Если прогрессия не введена, используем progression_base (score = reward.value) + score = reward.value + } + } + + const scoreStr = score >= 0 + ? `**${reward.project_name}+${formatScore(score)}**` + : `**${reward.project_name}-${formatScore(Math.abs(score))}**` + rewardStrings[reward.position] = scoreStr + }) + + // Функция для замены плейсхолдеров + const replacePlaceholders = (message, rewardStrings) => { + let result = message + // Сначала защищаем экранированные плейсхолдеры + const escapedMarkers = {} + for (let i = 0; i < 100; i++) { + const escaped = `\\$${i}` + const marker = `__ESCAPED_DOLLAR_${i}__` + if (result.includes(escaped)) { + escapedMarkers[marker] = escaped + result = result.replace(new RegExp(escaped.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), marker) + } + } + // Заменяем ${0}, ${1}, и т.д. + for (let i = 0; i < 100; i++) { + const placeholder = `\${${i}}` + if (rewardStrings[i]) { + result = result.replace(new RegExp(placeholder.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), rewardStrings[i]) + } + } + // Заменяем $0, $1, и т.д. (с конца, чтобы не заменить $1 в $10) + for (let i = 99; i >= 0; i--) { + if (rewardStrings[i]) { + const searchStr = `$${i}` + const regex = new RegExp(`\\$${i}(?!\\d)`, 'g') + result = result.replace(regex, rewardStrings[i]) + } + } + // Восстанавливаем экранированные + Object.entries(escapedMarkers).forEach(([marker, escaped]) => { + result = result.replace(new RegExp(marker, 'g'), escaped) + }) + return result + } + + // Формируем сообщение основной задачи + let mainTaskMessage = task.reward_message && task.reward_message.trim() !== '' + ? replacePlaceholders(task.reward_message, rewardStrings) + : task.name + + // Формируем сообщения подзадач + const subtaskMessages = [] + subtasks.forEach(subtask => { + if (!selectedSubtasks.has(subtask.task.id)) return + if (!subtask.task.reward_message || subtask.task.reward_message.trim() === '') return + + // Вычисляем score для наград подзадачи + const subtaskRewardStrings = {} + subtask.rewards.forEach(reward => { + let score = reward.value + const subtaskProgressionBase = subtask.task.progression_base + if (reward.use_progression) { + if (subtaskProgressionBase != null && value !== null && !isNaN(value)) { + score = (value / subtaskProgressionBase) * reward.value + } else if (hasProgression && value !== null && !isNaN(value)) { + score = (value / progressionBase) * reward.value + } else if (subtaskProgressionBase != null) { + // Если прогрессия не введена, используем progression_base подзадачи (score = reward.value) + score = reward.value + } else if (hasProgression) { + // Если у подзадачи нет progression_base, используем основной (score = reward.value) + score = reward.value + } + } + + const scoreStr = score >= 0 + ? `**${reward.project_name}+${formatScore(score)}**` + : `**${reward.project_name}-${formatScore(Math.abs(score))}**` + subtaskRewardStrings[reward.position] = scoreStr + }) + + const subtaskMessage = replacePlaceholders(subtask.task.reward_message, subtaskRewardStrings) + subtaskMessages.push(subtaskMessage) + }) + + // Формируем итоговое сообщение + let finalMessage = mainTaskMessage + subtaskMessages.forEach(subtaskMsg => { + finalMessage += '\n + ' + subtaskMsg + }) + + return finalMessage +} + function TaskDetail({ taskId, onClose, onRefresh, onTaskCompleted }) { const { authFetch } = useAuth() const [taskDetail, setTaskDetail] = useState(null) @@ -56,14 +215,10 @@ function TaskDetail({ taskId, onClose, onRefresh, onTaskCompleted }) { }) } - const handleComplete = async () => { + const handleComplete = async (shouldDelete = false) => { if (!taskDetail) return - // Валидация: если progression_base != null, то value обязателен - if (taskDetail.task.progression_base != null && !progressionValue.trim()) { - alert('Поле "Значение" обязательно для задач с прогрессией') - return - } + // Если прогрессия не введена, используем 0 (валидация не требуется) setIsCompleting(true) try { @@ -71,14 +226,25 @@ function TaskDetail({ taskId, onClose, onRefresh, onTaskCompleted }) { children_task_ids: Array.from(selectedSubtasks) } - if (taskDetail.task.progression_base != null && progressionValue.trim()) { - payload.value = parseFloat(progressionValue) - if (isNaN(payload.value)) { - throw new Error('Неверное значение') + // Если есть прогрессия, отправляем значение (или progression_base, если не введено) + if (taskDetail.task.progression_base != null) { + if (progressionValue.trim()) { + payload.value = parseFloat(progressionValue) + if (isNaN(payload.value)) { + throw new Error('Неверное значение') + } + } else { + // Если прогрессия не введена - используем progression_base + payload.value = taskDetail.task.progression_base } } - const response = await authFetch(`${API_URL}/${taskId}/complete`, { + // Используем единую ручку для выполнения и удаления + const endpoint = shouldDelete + ? `${API_URL}/${taskId}/complete-and-delete` + : `${API_URL}/${taskId}/complete` + + const response = await authFetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -115,7 +281,23 @@ function TaskDetail({ taskId, onClose, onRefresh, onTaskCompleted }) { const { task, rewards, subtasks } = taskDetail || {} const hasProgression = task?.progression_base != null - const canComplete = !hasProgression || (hasProgression && progressionValue.trim()) + // Кнопка всегда активна (если прогрессия не введена, используем 0) + const canComplete = true + + // Определяем, является ли задача одноразовой + // Одноразовая задача: когда оба поля null/undefined (из бэкенда видно, что в этом случае задача помечается как deleted) + // Бесконечная задача: когда хотя бы одно поле равно "0 day" или "0 week" и т.д. + // Повторяющаяся задача: когда есть значение (не null и не 0) + // Кнопка "Закрыть" показывается для задач, которые НЕ одноразовые (имеют повторение, даже если оно равно 0) + // Проверяем, что оба поля отсутствуют (null или undefined) + const isOneTime = (task?.repetition_period == null || task?.repetition_period === undefined) && + (task?.repetition_date == null || task?.repetition_date === undefined) + + // Формируем сообщение для Telegram в реальном времени + const telegramMessage = useMemo(() => { + if (!taskDetail) return '' + return formatTelegramMessage(task, rewards || [], subtasks || [], selectedSubtasks, progressionValue) + }, [taskDetail, task, rewards, subtasks, selectedSubtasks, progressionValue]) return (