4.5.0: Улучшена работа с задачами желаний
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m25s

This commit is contained in:
poignatov
2026-01-30 19:53:13 +03:00
parent 25f0c2697a
commit e955494dc8
8 changed files with 302 additions and 62 deletions

View File

@@ -1 +1 @@
4.4.1
4.5.0

View File

@@ -362,6 +362,7 @@ type WishlistItem struct {
MoreLockedConditions int `json:"more_locked_conditions,omitempty"`
UnlockConditions []UnlockConditionDisplay `json:"unlock_conditions,omitempty"`
LinkedTask *LinkedTask `json:"linked_task,omitempty"`
TasksCount int `json:"tasks_count,omitempty"` // Количество задач для этого желания
}
type UnlockConditionDisplay struct {
@@ -7170,12 +7171,14 @@ func (a *App) createTaskHandler(w http.ResponseWriter, r *http.Request) {
return
}
// Проверяем, что нет другой задачи с таким wishlist_id
// Проверяем, что нет другой активной (не удаленной и не выполненной) задачи с таким wishlist_id для этого пользователя
// Если задача была выполнена (completed > 0) или удалена, можно создать новую
var existingTaskID int
var existingTaskCompleted int
err = a.DB.QueryRow(`
SELECT id FROM tasks
WHERE wishlist_id = $1 AND deleted = FALSE
`, *req.WishlistID).Scan(&existingTaskID)
SELECT id, completed FROM tasks
WHERE wishlist_id = $1 AND user_id = $2 AND deleted = FALSE
`, *req.WishlistID, userID).Scan(&existingTaskID, &existingTaskCompleted)
if err != sql.ErrNoRows {
if err != nil {
@@ -7183,8 +7186,24 @@ func (a *App) createTaskHandler(w http.ResponseWriter, r *http.Request) {
sendErrorWithCORS(w, fmt.Sprintf("Error checking existing task: %v", err), http.StatusInternalServerError)
return
}
sendErrorWithCORS(w, "Task already exists for this wishlist item", http.StatusBadRequest)
return
// Если задача была выполнена (completed > 0), можно создать новую
if existingTaskCompleted > 0 {
log.Printf("Existing task %d for wishlist %d was completed (%d times), marking as deleted and allowing new task creation", existingTaskID, *req.WishlistID, existingTaskCompleted)
// Помечаем старую выполненную задачу как удаленную, чтобы избежать конфликта с уникальным индексом
_, err = a.DB.Exec(`
UPDATE tasks
SET deleted = TRUE
WHERE id = $1
`, existingTaskID)
if err != nil {
log.Printf("Error marking existing completed task as deleted: %v", err)
sendErrorWithCORS(w, fmt.Sprintf("Error marking existing task as deleted: %v", err), http.StatusInternalServerError)
return
}
} else {
sendErrorWithCORS(w, "Task already exists for this wishlist item", http.StatusBadRequest)
return
}
}
// Если название задачи не указано или пустое, используем название желания
@@ -7340,6 +7359,11 @@ func (a *App) createTaskHandler(w http.ResponseWriter, r *http.Request) {
if err != nil {
log.Printf("Error creating task: %v", err)
// Проверяем, не является ли это ошибкой уникального индекса
if strings.Contains(err.Error(), "unique") || strings.Contains(err.Error(), "duplicate") {
sendErrorWithCORS(w, "Task already exists for this wishlist item", http.StatusBadRequest)
return
}
sendErrorWithCORS(w, fmt.Sprintf("Error creating task: %v", err), http.StatusInternalServerError)
return
}
@@ -8697,7 +8721,8 @@ func (a *App) executeTask(taskID int, userID int, req CompleteTaskRequest) error
} else {
log.Printf("Wishlist item %d completed automatically after task %d completion", wishlistID.Int64, taskID)
// Обрабатываем политику награждения для всех задач, связанных с этим желанием
a.processWishlistRewardPolicy(int(wishlistID.Int64), userID)
// Исключаем задачу, которая была закрыта (taskID), чтобы не обрабатывать её повторно
a.processWishlistRewardPolicy(int(wishlistID.Int64), taskID)
}
}
@@ -9513,16 +9538,16 @@ func (a *App) getWishlistItemsWithConditions(userID int, includeCompleted bool)
}
}
// Загружаем связанную задачу, если есть
// Загружаем связанную задачу текущего пользователя, если есть
var linkedTaskID, linkedTaskCompleted, linkedTaskUserID sql.NullInt64
var linkedTaskName sql.NullString
var linkedTaskNextShowAt sql.NullTime
linkedTaskErr := a.DB.QueryRow(`
SELECT t.id, t.name, t.completed, t.next_show_at, t.user_id
FROM tasks t
WHERE t.wishlist_id = $1 AND t.deleted = FALSE
WHERE t.wishlist_id = $1 AND t.user_id = $2 AND t.deleted = FALSE
LIMIT 1
`, item.ID).Scan(&linkedTaskID, &linkedTaskName, &linkedTaskCompleted, &linkedTaskNextShowAt, &linkedTaskUserID)
`, item.ID, userID).Scan(&linkedTaskID, &linkedTaskName, &linkedTaskCompleted, &linkedTaskNextShowAt, &linkedTaskUserID)
if linkedTaskErr == nil && linkedTaskID.Valid {
linkedTask := &LinkedTask{
@@ -9544,6 +9569,31 @@ func (a *App) getWishlistItemsWithConditions(userID int, includeCompleted bool)
// Не возвращаем ошибку, просто не устанавливаем linked_task
}
// Подсчитываем общее количество не закрытых задач для этого желания (всех пользователей)
// Исключаем linked_task из подсчета, если она есть
// Учитываем только не закрытые задачи (completed = 0)
var tasksCount int
if linkedTaskID.Valid {
// Если есть linked_task, исключаем её из подсчета
err = a.DB.QueryRow(`
SELECT COUNT(*)
FROM tasks t
WHERE t.wishlist_id = $1 AND t.deleted = FALSE AND t.completed = 0 AND t.id != $2
`, item.ID, linkedTaskID.Int64).Scan(&tasksCount)
} else {
// Если нет linked_task, считаем все не закрытые задачи
err = a.DB.QueryRow(`
SELECT COUNT(*)
FROM tasks t
WHERE t.wishlist_id = $1 AND t.deleted = FALSE AND t.completed = 0
`, item.ID).Scan(&tasksCount)
}
if err != nil {
log.Printf("Error counting tasks for wishlist %d: %v", item.ID, err)
tasksCount = 0
}
item.TasksCount = tasksCount
items = append(items, *item)
}
@@ -10236,16 +10286,16 @@ func (a *App) getWishlistItemHandler(w http.ResponseWriter, r *http.Request) {
item.Unlocked = unlocked
}
// Загружаем связанную задачу, если есть
// Загружаем связанную задачу текущего пользователя, если есть
var linkedTaskID, linkedTaskCompleted, linkedTaskUserID sql.NullInt64
var linkedTaskName sql.NullString
var linkedTaskNextShowAt sql.NullTime
err = a.DB.QueryRow(`
SELECT t.id, t.name, t.completed, t.next_show_at, t.user_id
FROM tasks t
WHERE t.wishlist_id = $1 AND t.deleted = FALSE
WHERE t.wishlist_id = $1 AND t.user_id = $2 AND t.deleted = FALSE
LIMIT 1
`, itemID).Scan(&linkedTaskID, &linkedTaskName, &linkedTaskCompleted, &linkedTaskNextShowAt, &linkedTaskUserID)
`, itemID, userID).Scan(&linkedTaskID, &linkedTaskName, &linkedTaskCompleted, &linkedTaskNextShowAt, &linkedTaskUserID)
if err == nil && linkedTaskID.Valid {
linkedTask := &LinkedTask{
@@ -10267,6 +10317,31 @@ func (a *App) getWishlistItemHandler(w http.ResponseWriter, r *http.Request) {
// Не возвращаем ошибку, просто не устанавливаем linked_task
}
// Подсчитываем общее количество не закрытых задач для этого желания (всех пользователей)
// Исключаем linked_task из подсчета, если она есть
// Учитываем только не закрытые задачи (completed = 0)
var tasksCount int
if linkedTaskID.Valid {
// Если есть linked_task, исключаем её из подсчета
err = a.DB.QueryRow(`
SELECT COUNT(*)
FROM tasks t
WHERE t.wishlist_id = $1 AND t.deleted = FALSE AND t.completed = 0 AND t.id != $2
`, itemID, linkedTaskID.Int64).Scan(&tasksCount)
} else {
// Если нет linked_task, считаем все не закрытые задачи
err = a.DB.QueryRow(`
SELECT COUNT(*)
FROM tasks t
WHERE t.wishlist_id = $1 AND t.deleted = FALSE AND t.completed = 0
`, itemID).Scan(&tasksCount)
}
if err != nil {
log.Printf("Error counting tasks for wishlist %d: %v", itemID, err)
tasksCount = 0
}
item.TasksCount = tasksCount
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(item)
}
@@ -10780,8 +10855,26 @@ func (a *App) completeWishlistHandler(w http.ResponseWriter, r *http.Request) {
return
}
// Находим задачу пользователя для этого желания, чтобы исключить её из обработки
// (так же, как при закрытии через задачу)
var userTaskID int
err = a.DB.QueryRow(`
SELECT id FROM tasks
WHERE wishlist_id = $1 AND user_id = $2 AND deleted = FALSE
LIMIT 1
`, itemID, userID).Scan(&userTaskID)
// Если задача не найдена, используем 0 (не будет исключена, но это нормально, если задачи нет)
if err == sql.ErrNoRows {
userTaskID = 0
} else if err != nil {
log.Printf("Error finding user task for wishlist item %d: %v", itemID, err)
userTaskID = 0
}
// Обрабатываем политику награждения для всех задач, связанных с этим желанием
a.processWishlistRewardPolicy(itemID, userID)
// Исключаем задачу пользователя, который закрыл желание (если она есть)
a.processWishlistRewardPolicy(itemID, userTaskID)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
@@ -10791,12 +10884,26 @@ func (a *App) completeWishlistHandler(w http.ResponseWriter, r *http.Request) {
}
// processWishlistRewardPolicy обрабатывает политику награждения для всех задач, связанных с желанием
func (a *App) processWishlistRewardPolicy(wishlistItemID int, completingUserID int) {
rows, err := a.DB.Query(`
SELECT id, user_id, reward_policy
FROM tasks
WHERE wishlist_id = $1 AND deleted = FALSE
`, wishlistItemID)
// completedTaskID - ID задачи, которая была закрыта (исключается из обработки). Если 0, задача не найдена, но это нормально
func (a *App) processWishlistRewardPolicy(wishlistItemID int, completedTaskID int) {
var rows *sql.Rows
var err error
if completedTaskID == 0 {
// Если задача не найдена (желание закрывается напрямую, но у пользователя нет задачи),
// обрабатываем все задачи
rows, err = a.DB.Query(`
SELECT id, user_id, reward_policy
FROM tasks
WHERE wishlist_id = $1 AND deleted = FALSE
`, wishlistItemID)
} else {
// Исключаем задачу, которая была закрыта (через задачу или найдена при прямом закрытии желания)
rows, err = a.DB.Query(`
SELECT id, user_id, reward_policy
FROM tasks
WHERE wishlist_id = $1 AND deleted = FALSE AND id != $2
`, wishlistItemID, completedTaskID)
}
if err != nil {
log.Printf("Error querying tasks for wishlist item %d: %v", wishlistItemID, err)
return
@@ -10818,34 +10925,19 @@ func (a *App) processWishlistRewardPolicy(wishlistItemID int, completingUserID i
}
if policy == "personal" {
// Личная политика: задача выполняется только если пользователь сам завершил желание
if taskUserID == completingUserID {
// Пользователь завершил желание сам - помечаем задачу как выполненную
_, err = a.DB.Exec(`
UPDATE tasks
SET completed = completed + 1, last_completed_at = NOW()
WHERE id = $1
`, taskID)
if err != nil {
log.Printf("Error completing task %d: %v", taskID, err)
} else {
log.Printf("Task %d completed automatically after wishlist item %d completion (personal policy)", taskID, wishlistItemID)
}
// Личная политика: при закрытии задачи-желания другим пользователем, личная задача удаляется
_, err = a.DB.Exec(`
UPDATE tasks
SET deleted = TRUE
WHERE id = $1
`, taskID)
if err != nil {
log.Printf("Error deleting task %d: %v", taskID, err)
} else {
// Другой пользователь завершил желание - помечаем задачу как удалённую
_, err = a.DB.Exec(`
UPDATE tasks
SET deleted = TRUE
WHERE id = $1
`, taskID)
if err != nil {
log.Printf("Error deleting task %d: %v", taskID, err)
} else {
log.Printf("Task %d deleted because wishlist item %d was completed by another user (personal policy)", taskID, wishlistItemID)
}
log.Printf("Task %d deleted because wishlist item %d was completed by another user (personal policy)", taskID, wishlistItemID)
}
} else if policy == "general" {
// Общая политика: задача выполняется независимо от того, кто завершил желание
// Общая политика: при закрытии задачи-желания другим пользователем, общая задача закрывается
_, err = a.DB.Exec(`
UPDATE tasks
SET completed = completed + 1, last_completed_at = NOW()
@@ -12418,6 +12510,63 @@ func (a *App) getWishlistItemsByBoard(boardID int, userID int) ([]WishlistItem,
}
}
}
// Загружаем связанную задачу текущего пользователя, если есть
var linkedTaskID, linkedTaskCompleted, linkedTaskUserID sql.NullInt64
var linkedTaskName sql.NullString
var linkedTaskNextShowAt sql.NullTime
linkedTaskErr := a.DB.QueryRow(`
SELECT t.id, t.name, t.completed, t.next_show_at, t.user_id
FROM tasks t
WHERE t.wishlist_id = $1 AND t.user_id = $2 AND t.deleted = FALSE
LIMIT 1
`, item.ID, userID).Scan(&linkedTaskID, &linkedTaskName, &linkedTaskCompleted, &linkedTaskNextShowAt, &linkedTaskUserID)
if linkedTaskErr == nil && linkedTaskID.Valid {
linkedTask := &LinkedTask{
ID: int(linkedTaskID.Int64),
Name: linkedTaskName.String,
Completed: int(linkedTaskCompleted.Int64),
}
if linkedTaskNextShowAt.Valid {
nextShowAtStr := linkedTaskNextShowAt.Time.Format(time.RFC3339)
linkedTask.NextShowAt = &nextShowAtStr
}
if linkedTaskUserID.Valid {
userIDVal := int(linkedTaskUserID.Int64)
linkedTask.UserID = &userIDVal
}
item.LinkedTask = linkedTask
} else if linkedTaskErr != sql.ErrNoRows {
log.Printf("Error loading linked task for wishlist %d: %v", item.ID, linkedTaskErr)
// Не возвращаем ошибку, просто не устанавливаем linked_task
}
// Подсчитываем общее количество не закрытых задач для этого желания (всех пользователей)
// Исключаем linked_task из подсчета, если она есть
// Учитываем только не закрытые задачи (completed = 0)
var tasksCount int
if linkedTaskID.Valid {
// Если есть linked_task, исключаем её из подсчета
err = a.DB.QueryRow(`
SELECT COUNT(*)
FROM tasks t
WHERE t.wishlist_id = $1 AND t.deleted = FALSE AND t.completed = 0 AND t.id != $2
`, item.ID, linkedTaskID.Int64).Scan(&tasksCount)
} else {
// Если нет linked_task, считаем все не закрытые задачи
err = a.DB.QueryRow(`
SELECT COUNT(*)
FROM tasks t
WHERE t.wishlist_id = $1 AND t.deleted = FALSE AND t.completed = 0
`, item.ID).Scan(&tasksCount)
}
if err != nil {
log.Printf("Error counting tasks for wishlist %d: %v", item.ID, err)
tasksCount = 0
}
item.TasksCount = tasksCount
items = append(items, *item)
}

View File

@@ -0,0 +1,13 @@
-- Migration: Revert wishlist_id unique index fix
-- Date: 2026-01-30
--
-- This migration reverts the composite unique index back to the original
-- unique index that only checked wishlist_id.
-- Drop the composite unique index
DROP INDEX IF EXISTS idx_tasks_wishlist_id_user_id_unique;
-- Restore the original unique index on wishlist_id only
CREATE UNIQUE INDEX idx_tasks_wishlist_id_unique
ON tasks(wishlist_id)
WHERE wishlist_id IS NOT NULL AND deleted = FALSE;

View File

@@ -0,0 +1,16 @@
-- Migration: Fix wishlist_id unique index to allow multiple users
-- Date: 2026-01-30
--
-- This migration fixes the unique index on wishlist_id to allow multiple users
-- to create tasks for the same wishlist item. The old index only checked wishlist_id,
-- but now we need a composite unique index on (wishlist_id, user_id).
-- Drop the old unique index that only checked wishlist_id
DROP INDEX IF EXISTS idx_tasks_wishlist_id_unique;
-- Create a new composite unique index on (wishlist_id, user_id)
-- This allows multiple users to have tasks for the same wishlist item,
-- but prevents the same user from having multiple tasks for the same wishlist item
CREATE UNIQUE INDEX idx_tasks_wishlist_id_user_id_unique
ON tasks(wishlist_id, user_id)
WHERE wishlist_id IS NOT NULL AND deleted = FALSE;

View File

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

View File

@@ -167,6 +167,33 @@
border-radius: 18px;
}
.card-tasks-badge {
position: absolute;
bottom: 0.5rem;
background: #6b7280;
color: white;
border-radius: 50%;
padding: 0.25rem;
font-size: 0.75rem;
font-weight: 600;
min-width: 1.5rem;
height: 1.5rem;
text-align: center;
z-index: 5;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
display: flex;
align-items: center;
justify-content: center;
}
.card-tasks-badge-left {
left: 0.5rem;
}
.card-tasks-badge-right {
right: 0.5rem;
}
.card-name {
padding: 0.6rem 0 0;
font-weight: 600;

View File

@@ -232,6 +232,27 @@
transform: translateY(-1px);
}
.wishlist-detail-tasks-badge {
position: absolute;
bottom: -0.25rem;
left: -0.25rem;
background: #6b7280;
color: white;
border-radius: 50%;
padding: 0.2rem;
font-size: 0.7rem;
font-weight: 600;
min-width: 1.25rem;
height: 1.25rem;
text-align: center;
z-index: 10;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
border: 2px solid white;
display: flex;
align-items: center;
justify-content: center;
}
.wishlist-detail-linked-task {
margin-top: 0.75rem;
}

View File

@@ -368,11 +368,12 @@ function WishlistDetail({ wishlistId, onNavigate, onRefresh, boardId }) {
{wishlistItem.linked_task && wishlistItem.linked_task.user_id === user?.id ? (
<div className="wishlist-detail-linked-task">
<div className="linked-task-label-header">Связанная задача:</div>
<div
className="task-item"
onClick={handleTaskItemClick}
>
<div className="task-item-content">
<div style={{ position: 'relative', display: 'inline-block', width: '100%' }}>
<div
className="task-item"
onClick={handleTaskItemClick}
>
<div className="task-item-content">
<div
className="task-checkmark"
onClick={handleTaskCheckmarkClick}
@@ -439,6 +440,12 @@ function WishlistDetail({ wishlistId, onNavigate, onRefresh, boardId }) {
)}
</div>
</div>
{wishlistItem?.tasks_count > 0 && (
<div className="wishlist-detail-tasks-badge">
{wishlistItem.tasks_count}
</div>
)}
</div>
</div>
) : (
<div className="wishlist-detail-actions">
@@ -459,16 +466,23 @@ function WishlistDetail({ wishlistId, onNavigate, onRefresh, boardId }) {
>
{isCompleting ? 'Завершение...' : 'Завершить'}
</button>
<button
onClick={handleCreateTask}
className="wishlist-detail-create-task-button"
title="Создать задачу"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 11l3 3L22 4"></path>
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"></path>
</svg>
</button>
<div style={{ position: 'relative', display: 'inline-block' }}>
<button
onClick={handleCreateTask}
className="wishlist-detail-create-task-button"
title="Создать задачу"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 11l3 3L22 4"></path>
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"></path>
</svg>
</button>
{wishlistItem?.tasks_count > 0 && (
<div className="wishlist-detail-tasks-badge">
{wishlistItem.tasks_count}
</div>
)}
</div>
</>
)}
</div>