From 508355dcb349d172156e4312ea0280cc1ca64690 Mon Sep 17 00:00:00 2001 From: poignatov Date: Tue, 6 Jan 2026 15:56:52 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B0=20=D1=84=D1=83=D0=BD=D0=BA=D1=86=D0=B8=D0=BE?= =?UTF-8?q?=D0=BD=D0=B0=D0=BB=D1=8C=D0=BD=D0=BE=D1=81=D1=82=D1=8C=20=D0=BE?= =?UTF-8?q?=D1=82=D0=BA=D0=BB=D0=B0=D0=B4=D1=8B=D0=B2=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=87=20(next=5Fshow=5Fat)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- VERSION | 2 +- play-life-backend/main.go | 111 +++++++- .../migrations/017_add_next_show_at.sql | 14 + play-life-web/package.json | 2 +- play-life-web/src/components/TaskList.css | 163 +++++++++++ play-life-web/src/components/TaskList.jsx | 256 +++++++++++++++--- 6 files changed, 502 insertions(+), 46 deletions(-) create mode 100644 play-life-backend/migrations/017_add_next_show_at.sql diff --git a/VERSION b/VERSION index 3ad0595..944880f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -3.1.5 +3.2.0 diff --git a/play-life-backend/main.go b/play-life-backend/main.go index 63f451e..a5b464e 100644 --- a/play-life-backend/main.go +++ b/play-life-backend/main.go @@ -205,6 +205,7 @@ type Task struct { Name string `json:"name"` Completed int `json:"completed"` LastCompletedAt *string `json:"last_completed_at,omitempty"` + NextShowAt *string `json:"next_show_at,omitempty"` RewardMessage *string `json:"reward_message,omitempty"` ProgressionBase *float64 `json:"progression_base,omitempty"` RepetitionPeriod *string `json:"repetition_period,omitempty"` @@ -261,6 +262,10 @@ type CompleteTaskRequest struct { ChildrenTaskIDs []int `json:"children_task_ids,omitempty"` } +type PostponeTaskRequest struct { + NextShowAt *string `json:"next_show_at"` +} + // ============================================ // Auth types // ============================================ @@ -2916,6 +2921,11 @@ func (a *App) initPlayLifeDB() error { log.Printf("Warning: Failed to apply migration 016 (add repetition_period): %v", err) } + // Apply migration 017: Add next_show_at to tasks + if _, err := a.DB.Exec("ALTER TABLE tasks ADD COLUMN IF NOT EXISTS next_show_at TIMESTAMP WITH TIME ZONE"); err != nil { + log.Printf("Warning: Failed to apply migration 017 (add next_show_at): %v", err) + } + // Создаем таблицу reward_configs createRewardConfigsTable := ` CREATE TABLE IF NOT EXISTS reward_configs ( @@ -3598,6 +3608,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}/postpone", app.postponeTaskHandler).Methods("POST", "OPTIONS") // Admin operations protected.HandleFunc("/admin/recreate-mv", app.recreateMaterializedViewHandler).Methods("POST", "OPTIONS") @@ -6260,6 +6271,7 @@ func (a *App) getTasksHandler(w http.ResponseWriter, r *http.Request) { t.name, t.completed, t.last_completed_at, + t.next_show_at, t.repetition_period::text, t.progression_base, COALESCE(( @@ -6301,6 +6313,7 @@ func (a *App) getTasksHandler(w http.ResponseWriter, r *http.Request) { for rows.Next() { var task Task var lastCompletedAt sql.NullString + var nextShowAt sql.NullString var repetitionPeriod sql.NullString var progressionBase sql.NullFloat64 var projectNames pq.StringArray @@ -6310,7 +6323,8 @@ func (a *App) getTasksHandler(w http.ResponseWriter, r *http.Request) { &task.ID, &task.Name, &task.Completed, - &lastCompletedAt, + &lastCompletedAt, + &nextShowAt, &repetitionPeriod, &progressionBase, &task.SubtasksCount, @@ -6325,6 +6339,9 @@ func (a *App) getTasksHandler(w http.ResponseWriter, r *http.Request) { if lastCompletedAt.Valid { task.LastCompletedAt = &lastCompletedAt.String } + if nextShowAt.Valid { + task.NextShowAt = &nextShowAt.String + } if repetitionPeriod.Valid { task.RepetitionPeriod = &repetitionPeriod.String } @@ -6387,17 +6404,18 @@ func (a *App) getTaskDetailHandler(w http.ResponseWriter, r *http.Request) { var rewardMessage sql.NullString var progressionBase sql.NullFloat64 var lastCompletedAt sql.NullString + var nextShowAt sql.NullString var repetitionPeriod sql.NullString // Сначала получаем значение как строку напрямую, чтобы избежать проблем с NULL var repetitionPeriodStr string err = a.DB.QueryRow(` - SELECT id, name, completed, last_completed_at, reward_message, progression_base, + SELECT id, name, completed, last_completed_at, next_show_at, reward_message, progression_base, CASE WHEN repetition_period IS NULL THEN '' ELSE repetition_period::text END as repetition_period FROM tasks WHERE id = $1 AND user_id = $2 AND deleted = FALSE `, taskID, userID).Scan( - &task.ID, &task.Name, &task.Completed, &lastCompletedAt, &rewardMessage, &progressionBase, &repetitionPeriodStr, + &task.ID, &task.Name, &task.Completed, &lastCompletedAt, &nextShowAt, &rewardMessage, &progressionBase, &repetitionPeriodStr, ) log.Printf("Scanned repetition_period for task %d: String='%s'", taskID, repetitionPeriodStr) @@ -6428,6 +6446,9 @@ func (a *App) getTaskDetailHandler(w http.ResponseWriter, r *http.Request) { if lastCompletedAt.Valid { task.LastCompletedAt = &lastCompletedAt.String } + if nextShowAt.Valid { + task.NextShowAt = &nextShowAt.String + } if repetitionPeriod.Valid && repetitionPeriod.String != "" { task.RepetitionPeriod = &repetitionPeriod.String log.Printf("Task %d has repetition_period: %s", task.ID, repetitionPeriod.String) @@ -7460,21 +7481,21 @@ func (a *App) completeTaskHandler(w http.ResponseWriter, r *http.Request) { // Задача никогда не будет переноситься в выполненные _, err = a.DB.Exec(` UPDATE tasks - SET completed = completed + 1 + SET completed = completed + 1, next_show_at = NULL WHERE id = $1 `, taskID) } else { - // Обычный период: обновляем счетчик и last_completed_at + // Обычный период: обновляем счетчик и last_completed_at, сбрасываем next_show_at _, err = a.DB.Exec(` UPDATE tasks - SET completed = completed + 1, last_completed_at = NOW() + SET completed = completed + 1, last_completed_at = NOW(), next_show_at = NULL WHERE id = $1 `, taskID) } } else { _, err = a.DB.Exec(` UPDATE tasks - SET completed = completed + 1, last_completed_at = NOW(), deleted = TRUE + SET completed = completed + 1, last_completed_at = NOW(), next_show_at = NULL, deleted = TRUE WHERE id = $1 `, taskID) } @@ -7514,6 +7535,82 @@ func (a *App) completeTaskHandler(w http.ResponseWriter, r *http.Request) { }) } +// postponeTaskHandler переносит задачу на указанную дату +func (a *App) postponeTaskHandler(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 PostponeTaskRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + log.Printf("Error decoding postpone task request: %v", err) + sendErrorWithCORS(w, "Invalid request body", 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 + } + + // Если NextShowAt == nil, устанавливаем next_show_at в NULL + // Иначе парсим дату и устанавливаем значение + var nextShowAtValue interface{} + if req.NextShowAt == nil || *req.NextShowAt == "" { + nextShowAtValue = nil + } else { + nextShowAt, err := time.Parse(time.RFC3339, *req.NextShowAt) + if err != nil { + log.Printf("Error parsing next_show_at: %v", err) + sendErrorWithCORS(w, "Invalid date format. Use RFC3339 format", http.StatusBadRequest) + return + } + nextShowAtValue = nextShowAt + } + + // Обновляем next_show_at + _, err = a.DB.Exec(` + UPDATE tasks + SET next_show_at = $1 + WHERE id = $2 AND user_id = $3 + `, nextShowAtValue, taskID, userID) + if err != nil { + log.Printf("Error updating next_show_at: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Error updating next_show_at: %v", err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "message": "Task postponed successfully", + }) +} + // todoistDisconnectHandler отключает интеграцию Todoist func (a *App) todoistDisconnectHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { diff --git a/play-life-backend/migrations/017_add_next_show_at.sql b/play-life-backend/migrations/017_add_next_show_at.sql new file mode 100644 index 0000000..636dc23 --- /dev/null +++ b/play-life-backend/migrations/017_add_next_show_at.sql @@ -0,0 +1,14 @@ +-- Migration: Add next_show_at field to tasks table +-- This script adds the next_show_at field for postponing tasks + +-- ============================================ +-- Add next_show_at column +-- ============================================ +ALTER TABLE tasks +ADD COLUMN IF NOT EXISTS next_show_at TIMESTAMP WITH TIME ZONE; + +-- ============================================ +-- Comments for documentation +-- ============================================ +COMMENT ON COLUMN tasks.next_show_at IS 'Date when task should be shown again (NULL means use last_completed_at + period)'; + diff --git a/play-life-web/package.json b/play-life-web/package.json index 4d05479..0e5bb4f 100644 --- a/play-life-web/package.json +++ b/play-life-web/package.json @@ -1,6 +1,6 @@ { "name": "play-life-web", - "version": "3.1.5", + "version": "3.2.0", "type": "module", "scripts": { "dev": "vite", diff --git a/play-life-web/src/components/TaskList.css b/play-life-web/src/components/TaskList.css index 4310a8d..867fbff 100644 --- a/play-life-web/src/components/TaskList.css +++ b/play-life-web/src/components/TaskList.css @@ -96,6 +96,13 @@ gap: 0.5rem; } +.task-name-wrapper { + flex: 1; + display: flex; + flex-direction: column; + gap: 0.125rem; +} + .task-name { font-size: 1rem; font-weight: 500; @@ -105,6 +112,12 @@ gap: 0.25rem; } +.task-next-show-date { + font-size: 0.75rem; + color: #6b7280; + font-weight: 400; +} + .task-subtasks-count { color: #9ca3af; font-size: 0.875rem; @@ -128,6 +141,156 @@ font-weight: 500; } +.task-postpone-button { + background: none; + border: none; + color: #6b7280; + cursor: pointer; + padding: 0.25rem; + border-radius: 0.25rem; + transition: all 0.2s; + display: flex; + align-items: center; + justify-content: center; +} + +.task-postpone-button:hover { + background: #f3f4f6; + color: #6366f1; +} + +.task-postpone-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: 50; +} + +.task-postpone-modal { + background: white; + border-radius: 0.5rem; + max-width: 400px; + width: 90%; + box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1); +} + +.task-postpone-modal-header { + padding: 1.5rem; + border-bottom: 1px solid #e5e7eb; + display: flex; + justify-content: space-between; + align-items: center; +} + +.task-postpone-modal-header h3 { + margin: 0; + font-size: 1.25rem; + font-weight: 600; + color: #1f2937; +} + +.task-postpone-close-button { + background: none; + border: none; + font-size: 1.5rem; + color: #6b7280; + cursor: pointer; + padding: 0; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 0.25rem; + transition: all 0.2s; +} + +.task-postpone-close-button:hover { + background: #f3f4f6; + color: #1f2937; +} + +.task-postpone-modal-content { + padding: 1.5rem; +} + +.task-postpone-task-name { + margin: 0 0 1rem 0; + font-size: 1rem; + color: #1f2937; + font-weight: 500; +} + +.task-postpone-label { + display: flex; + flex-direction: column; + gap: 0.5rem; + font-size: 0.875rem; + font-weight: 500; + color: #374151; +} + +.task-postpone-input { + padding: 0.75rem; + border: 1px solid #d1d5db; + border-radius: 0.375rem; + font-size: 1rem; + transition: all 0.2s; +} + +.task-postpone-input:focus { + outline: none; + border-color: #6366f1; + box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1); +} + +.task-postpone-modal-actions { + padding: 1rem 1.5rem 1.5rem; + display: flex; + gap: 0.75rem; + justify-content: flex-end; +} + +.task-postpone-cancel-button, +.task-postpone-submit-button { + padding: 0.75rem 1.5rem; + border: none; + border-radius: 0.375rem; + font-size: 1rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; +} + +.task-postpone-cancel-button { + background: #f3f4f6; + color: #374151; +} + +.task-postpone-cancel-button:hover:not(:disabled) { + background: #e5e7eb; +} + +.task-postpone-submit-button { + background: #6366f1; + color: white; +} + +.task-postpone-submit-button:hover:not(:disabled) { + background: #4f46e5; +} + +.task-postpone-submit-button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + .task-menu-button { background: none; border: none; diff --git a/play-life-web/src/components/TaskList.jsx b/play-life-web/src/components/TaskList.jsx index ba08b61..b2a11de 100644 --- a/play-life-web/src/components/TaskList.jsx +++ b/play-life-web/src/components/TaskList.jsx @@ -13,6 +13,9 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, onRefresh }) { const [selectedTaskForDetail, setSelectedTaskForDetail] = useState(null) const [isCompleting, setIsCompleting] = useState(false) const [expandedCompleted, setExpandedCompleted] = useState({}) + const [selectedTaskForPostpone, setSelectedTaskForPostpone] = useState(null) + const [postponeDate, setPostponeDate] = useState('') + const [isPostponing, setIsPostponing] = useState(false) // Загружаем состояние раскрытия "Бесконечные" из localStorage (по умолчанию true) const [expandedInfinite, setExpandedInfinite] = useState(() => { try { @@ -87,6 +90,94 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, onRefresh }) { onNavigate?.('task-form', { taskId: undefined }) } + const handlePostponeClick = (task, e) => { + e.stopPropagation() + setSelectedTaskForPostpone(task) + // Устанавливаем дату по умолчанию - завтра + const tomorrow = new Date() + tomorrow.setDate(tomorrow.getDate() + 1) + tomorrow.setHours(0, 0, 0, 0) + setPostponeDate(tomorrow.toISOString().split('T')[0]) + } + + const handlePostponeSubmit = async () => { + if (!selectedTaskForPostpone || !postponeDate) return + + setIsPostponing(true) + try { + // Преобразуем дату в ISO формат с временем + const dateObj = new Date(postponeDate) + dateObj.setHours(0, 0, 0, 0) + const isoDate = dateObj.toISOString() + + const response = await authFetch(`${API_URL}/${selectedTaskForPostpone.id}/postpone`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ next_show_at: isoDate }), + }) + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + throw new Error(errorData.message || 'Ошибка при переносе задачи') + } + + // Обновляем список + if (onRefresh) { + onRefresh() + } + + // Закрываем модальное окно + setSelectedTaskForPostpone(null) + setPostponeDate('') + } catch (err) { + console.error('Error postponing task:', err) + alert(err.message || 'Ошибка при переносе задачи') + } finally { + setIsPostponing(false) + } + } + + const handlePostponeReset = async () => { + if (!selectedTaskForPostpone) return + + setIsPostponing(true) + try { + const response = await authFetch(`${API_URL}/${selectedTaskForPostpone.id}/postpone`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ next_show_at: null }), + }) + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + throw new Error(errorData.message || 'Ошибка при сбросе переноса задачи') + } + + // Обновляем список + if (onRefresh) { + onRefresh() + } + + // Закрываем модальное окно + setSelectedTaskForPostpone(null) + setPostponeDate('') + } catch (err) { + console.error('Error resetting postpone:', err) + alert(err.message || 'Ошибка при сбросе переноса задачи') + } finally { + setIsPostponing(false) + } + } + + const handlePostponeClose = () => { + setSelectedTaskForPostpone(null) + setPostponeDate('') + } + const toggleCompletedExpanded = (projectName) => { setExpandedCompleted(prev => ({ ...prev, @@ -195,31 +286,42 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, onRefresh }) { let isCompleted = false let isInfinite = false - // Если у задачи период повторения = 0, она в бесконечных - if (task.repetition_period && isZeroPeriod(task.repetition_period)) { + // Если next_show_at установлен, задача всегда в выполненных (если дата в будущем) + // даже если она бесконечная + if (task.next_show_at) { + const nextShowDate = new Date(task.next_show_at) + nextShowDate.setHours(0, 0, 0, 0) + isCompleted = nextShowDate.getTime() > today.getTime() + isInfinite = false + } else if (task.repetition_period && isZeroPeriod(task.repetition_period)) { + // Если у задачи период повторения = 0 и нет next_show_at, она в бесконечных isInfinite = true isCompleted = false } else if (task.repetition_period) { // Если есть repetition_period (и он не 0), проверяем логику повторения + // Используем last_completed_at + period + let nextDueDate = null + if (task.last_completed_at) { const lastCompleted = new Date(task.last_completed_at) - const nextDueDate = addIntervalToDate(lastCompleted, task.repetition_period) + nextDueDate = addIntervalToDate(lastCompleted, task.repetition_period) + } + + if (nextDueDate) { + // Округляем до начала дня + nextDueDate.setHours(0, 0, 0, 0) - if (nextDueDate) { - // Округляем до начала дня - nextDueDate.setHours(0, 0, 0, 0) - - // Если nextDueDate > today, то задача в выполненных - isCompleted = nextDueDate.getTime() > today.getTime() - } else { - // Если не удалось распарсить интервал, используем старую логику + // Если nextDueDate > today, то задача в выполненных + isCompleted = nextDueDate.getTime() > today.getTime() + } else { + // Если не удалось определить дату, используем старую логику + if (task.last_completed_at) { const completedDate = new Date(task.last_completed_at) completedDate.setHours(0, 0, 0, 0) isCompleted = completedDate.getTime() === today.getTime() + } else { + isCompleted = false } - } else { - // Если нет last_completed_at, то в обычной группе - isCompleted = false } } else { // Если repetition_period == null, используем старую логику @@ -277,32 +379,66 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, onRefresh }) {
-
- {task.name} - {hasSubtasks && ( - (+{task.subtasks_count}) - )} +
+
+ {task.name} + {hasSubtasks && ( + (+{task.subtasks_count}) + )} + {hasProgression && ( + + + + + )} +
+ {task.next_show_at && (() => { + const showDate = new Date(task.next_show_at) + showDate.setHours(0, 0, 0, 0) + const today = new Date() + today.setHours(0, 0, 0, 0) + const tomorrow = new Date(today) + tomorrow.setDate(tomorrow.getDate() + 1) + + let dateText + if (showDate.getTime() === today.getTime()) { + dateText = 'Сегодня' + } else if (showDate.getTime() === tomorrow.getTime()) { + dateText = 'Завтра' + } else { + dateText = showDate.toLocaleDateString('ru-RU', { day: 'numeric', month: 'long', year: 'numeric' }) + } + + return ( +
+ {dateText} +
+ ) + })()}
- {hasProgression && ( - - - - - )}
- {task.completed} +
@@ -365,12 +501,14 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, onRefresh }) {

{projectName}

+ {/* Обычные задачи */} {group.notCompleted.length > 0 && (
{group.notCompleted.map(renderTaskItem)}
)} + {/* Бесконечные задачи */} {hasInfinite && (
+
+
+

{selectedTaskForPostpone.name}

+ +
+
+ + +
+ + + )} ) }