diff --git a/.cursor/rules/version_bump_and_push.mdc b/.cursor/rules/version_bump_and_push.mdc index 37bf7e5..4ad0aed 100644 --- a/.cursor/rules/version_bump_and_push.mdc +++ b/.cursor/rules/version_bump_and_push.mdc @@ -25,7 +25,7 @@ alwaysApply: true ### 3. Проанализируй git diff -Выполни `git diff --staged` и `git diff` для анализа изменений. На основе изменений составь **короткий commit message** (максимум 50 символов) на русском языке, описывающий суть изменений. +Выполни `git diff --staged` и `git diff` для анализа изменений. На основе изменений составь **короткий commit message** (максимум 50 символов) на русском языке, описывающий суть изменений. В начале commit message должна быть указана версия на которую осуществился переход в формате "1.2.3: Коммит мессадж" ### 4. Закоммить изменения diff --git a/VERSION b/VERSION index a72fd67..2a5310b 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -3.28.0 +3.28.2 diff --git a/play-life-backend/main.go b/play-life-backend/main.go index cc28596..9ca98e9 100644 --- a/play-life-backend/main.go +++ b/play-life-backend/main.go @@ -243,10 +243,17 @@ type Subtask struct { Rewards []Reward `json:"rewards"` } +type WishlistInfo struct { + ID int `json:"id"` + Name string `json:"name"` + Unlocked bool `json:"unlocked"` +} + type TaskDetail struct { Task Task `json:"task"` Rewards []Reward `json:"rewards"` Subtasks []Subtask `json:"subtasks"` + WishlistInfo *WishlistInfo `json:"wishlist_info,omitempty"` // Test-specific fields (only present if task has config_id) WordsCount *int `json:"words_count,omitempty"` MaxCards *int `json:"max_cards,omitempty"` @@ -2889,6 +2896,18 @@ func (a *App) initAuthDB() error { // Не возвращаем ошибку, чтобы приложение могло запуститься } + // Apply migration 028: Optimize task queries with composite index + if err := a.applyMigration028(); err != nil { + log.Printf("Warning: Failed to apply migration 028: %v", err) + // Не возвращаем ошибку, чтобы приложение могло запуститься + } + + // Apply migration 029: Add covering indexes + if err := a.applyMigration029(); err != nil { + log.Printf("Warning: Failed to apply migration 029: %v", err) + // Не возвращаем ошибку, чтобы приложение могло запуститься + } + // Clean up expired refresh tokens (only those with expiration date set) a.DB.Exec("DELETE FROM refresh_tokens WHERE expires_at IS NOT NULL AND expires_at < NOW()") @@ -3336,6 +3355,96 @@ func (a *App) applyMigration025() error { return nil } +// applyMigration028 применяет миграцию 028_optimize_task_queries.sql +func (a *App) applyMigration028() error { + log.Printf("Applying migration 028: Optimize task queries with composite index") + + // Проверяем, существует ли уже индекс + var exists bool + err := a.DB.QueryRow(` + SELECT EXISTS ( + SELECT FROM pg_indexes + WHERE schemaname = 'public' + AND indexname = 'idx_tasks_id_user_deleted' + ) + `).Scan(&exists) + if err != nil { + return fmt.Errorf("failed to check if index exists: %w", err) + } + if exists { + log.Printf("Migration 028 already applied (index idx_tasks_id_user_deleted exists), skipping") + return nil + } + + // Читаем SQL файл миграции + migrationPath := "/migrations/028_optimize_task_queries.sql" + if _, err := os.Stat(migrationPath); os.IsNotExist(err) { + // Пробуем альтернативный путь (для локальной разработки) + migrationPath = "play-life-backend/migrations/028_optimize_task_queries.sql" + if _, err := os.Stat(migrationPath); os.IsNotExist(err) { + migrationPath = "migrations/028_optimize_task_queries.sql" + } + } + + migrationSQL, err := os.ReadFile(migrationPath) + if err != nil { + return fmt.Errorf("failed to read migration file %s: %w", migrationPath, err) + } + + // Выполняем миграцию + if _, err := a.DB.Exec(string(migrationSQL)); err != nil { + return fmt.Errorf("failed to execute migration 028: %w", err) + } + + log.Printf("Migration 028 applied successfully") + return nil +} + +// applyMigration029 применяет миграцию 029_add_covering_indexes.sql +func (a *App) applyMigration029() error { + log.Printf("Applying migration 029: Add covering indexes") + + // Проверяем, существует ли уже индекс для подзадач + var exists bool + err := a.DB.QueryRow(` + SELECT EXISTS ( + SELECT FROM pg_indexes + WHERE schemaname = 'public' + AND indexname = 'idx_tasks_parent_deleted_covering' + ) + `).Scan(&exists) + if err != nil { + return fmt.Errorf("failed to check if index exists: %w", err) + } + if exists { + log.Printf("Migration 029 already applied (covering indexes exist), skipping") + return nil + } + + // Читаем SQL файл миграции + migrationPath := "/migrations/029_add_covering_indexes.sql" + if _, err := os.Stat(migrationPath); os.IsNotExist(err) { + // Пробуем альтернативный путь (для локальной разработки) + migrationPath = "play-life-backend/migrations/029_add_covering_indexes.sql" + if _, err := os.Stat(migrationPath); os.IsNotExist(err) { + migrationPath = "migrations/029_add_covering_indexes.sql" + } + } + + migrationSQL, err := os.ReadFile(migrationPath) + if err != nil { + return fmt.Errorf("failed to read migration file %s: %w", migrationPath, err) + } + + // Выполняем миграцию + if _, err := a.DB.Exec(string(migrationSQL)); err != nil { + return fmt.Errorf("failed to execute migration 029: %w", err) + } + + log.Printf("Migration 029 applied successfully") + return nil +} + func (a *App) initPlayLifeDB() error { // Создаем таблицу projects createProjectsTable := ` @@ -7331,6 +7440,9 @@ func (a *App) getTaskDetailHandler(w http.ResponseWriter, r *http.Request) { log.Printf("Error querying subtasks: %v", err) } else { defer subtaskRows.Close() + subtaskMap := make(map[int]*Subtask) + subtaskIDs := make([]int, 0) + for subtaskRows.Next() { var subtaskTask Task var subtaskRewardMessage sql.NullString @@ -7356,33 +7468,56 @@ func (a *App) getTaskDetailHandler(w http.ResponseWriter, r *http.Request) { subtaskTask.LastCompletedAt = &subtaskLastCompletedAt.String } - // Получаем награды подзадачи - subtaskRewards := make([]Reward, 0) - subtaskRewardRows, err := a.DB.Query(` - SELECT rc.id, rc.position, p.name AS project_name, rc.value, rc.use_progression + subtaskIDs = append(subtaskIDs, subtaskTask.ID) + subtask := Subtask{ + Task: subtaskTask, + Rewards: make([]Reward, 0), + } + subtaskMap[subtaskTask.ID] = &subtask + } + + // Загружаем все награды всех подзадач одним запросом + if len(subtaskIDs) > 0 { + placeholders := make([]string, len(subtaskIDs)) + args := make([]interface{}, len(subtaskIDs)) + for i, id := range subtaskIDs { + placeholders[i] = fmt.Sprintf("$%d", i+1) + args[i] = id + } + + query := fmt.Sprintf(` + SELECT rc.task_id, rc.id, 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 - `, subtaskTask.ID) + WHERE rc.task_id = ANY(ARRAY[%s]) + ORDER BY rc.task_id, rc.position + `, strings.Join(placeholders, ",")) - if err == nil { + subtaskRewardRows, err := a.DB.Query(query, args...) + if err != nil { + log.Printf("Error querying subtask rewards: %v", err) + } else { + defer subtaskRewardRows.Close() for subtaskRewardRows.Next() { + var taskID int var reward Reward - err := subtaskRewardRows.Scan(&reward.ID, &reward.Position, &reward.ProjectName, &reward.Value, &reward.UseProgression) + err := subtaskRewardRows.Scan(&taskID, &reward.ID, &reward.Position, &reward.ProjectName, &reward.Value, &reward.UseProgression) if err != nil { log.Printf("Error scanning subtask reward: %v", err) continue } - subtaskRewards = append(subtaskRewards, reward) + if subtask, exists := subtaskMap[taskID]; exists { + subtask.Rewards = append(subtask.Rewards, reward) + } } - subtaskRewardRows.Close() } + } - subtasks = append(subtasks, Subtask{ - Task: subtaskTask, - Rewards: subtaskRewards, - }) + // Преобразуем map в slice, сохраняя порядок по ID + for _, id := range subtaskIDs { + if subtask, exists := subtaskMap[id]; exists { + subtasks = append(subtasks, *subtask) + } } } @@ -7392,6 +7527,32 @@ func (a *App) getTaskDetailHandler(w http.ResponseWriter, r *http.Request) { Subtasks: subtasks, } + // Если задача связана с wishlist, загружаем базовую информацию о wishlist + if wishlistID.Valid { + var wishlistName string + err := a.DB.QueryRow(` + SELECT name + FROM wishlist_items + WHERE id = $1 AND deleted = FALSE + `, wishlistID.Int64).Scan(&wishlistName) + + if err == nil { + unlocked, err := a.checkWishlistUnlock(int(wishlistID.Int64), userID) + if err != nil { + log.Printf("Error checking wishlist unlock status: %v", err) + unlocked = false + } + + response.WishlistInfo = &WishlistInfo{ + ID: int(wishlistID.Int64), + Name: wishlistName, + Unlocked: unlocked, + } + } else if err != sql.ErrNoRows { + log.Printf("Error loading wishlist info for task %d: %v", taskID, err) + } + } + // Если задача - тест (есть config_id), загружаем данные конфигурации if configID.Valid { var wordsCount int diff --git a/play-life-backend/migrations/028_optimize_task_queries.sql b/play-life-backend/migrations/028_optimize_task_queries.sql new file mode 100644 index 0000000..e39f962 --- /dev/null +++ b/play-life-backend/migrations/028_optimize_task_queries.sql @@ -0,0 +1,14 @@ +-- Migration: Optimize task queries with composite index +-- Date: 2026-01-24 +-- +-- This migration adds a composite index to optimize the task detail query: +-- WHERE id = $1 AND user_id = $2 AND deleted = FALSE +-- +-- The index uses a partial index with WHERE deleted = FALSE to reduce index size +-- and improve query performance for active (non-deleted) tasks. + +CREATE INDEX IF NOT EXISTS idx_tasks_id_user_deleted +ON tasks(id, user_id, deleted) +WHERE deleted = FALSE; + +COMMENT ON INDEX idx_tasks_id_user_deleted IS 'Composite index for optimizing task detail queries with id, user_id, and deleted filter. Partial index for non-deleted tasks only.'; diff --git a/play-life-backend/migrations/029_add_covering_indexes.sql b/play-life-backend/migrations/029_add_covering_indexes.sql new file mode 100644 index 0000000..057050e --- /dev/null +++ b/play-life-backend/migrations/029_add_covering_indexes.sql @@ -0,0 +1,25 @@ +-- Migration: Add covering indexes for task detail queries +-- Date: 2026-01-25 +-- +-- This migration adds covering indexes to optimize queries by including +-- all needed columns in the index, avoiding table lookups. +-- +-- Covering indexes allow PostgreSQL to perform index-only scans, +-- getting all data directly from the index without accessing the table. + +-- Covering index for subtasks query +-- Includes all columns needed for subtasks selection to avoid table lookups +CREATE INDEX IF NOT EXISTS idx_tasks_parent_deleted_covering +ON tasks(parent_task_id, deleted, id) +INCLUDE (name, completed, last_completed_at, reward_message, progression_base) +WHERE deleted = FALSE; + +-- Covering index for wishlist name lookup +-- Includes name and deleted flag for quick lookup without table access +CREATE INDEX IF NOT EXISTS idx_wishlist_items_id_deleted_covering +ON wishlist_items(id, deleted) +INCLUDE (name) +WHERE deleted = FALSE; + +COMMENT ON INDEX idx_tasks_parent_deleted_covering IS 'Covering index for subtasks query - includes all selected columns to avoid table lookups. Enables index-only scans for better performance.'; +COMMENT ON INDEX idx_wishlist_items_id_deleted_covering IS 'Covering index for wishlist name lookup - includes name to avoid table lookup. Enables index-only scans for better performance.'; diff --git a/play-life-web/package-lock.json b/play-life-web/package-lock.json index 754a55a..4d453a8 100644 --- a/play-life-web/package-lock.json +++ b/play-life-web/package-lock.json @@ -1,12 +1,12 @@ { "name": "play-life-web", - "version": "3.19.0", + "version": "3.28.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "play-life-web", - "version": "3.19.0", + "version": "3.28.1", "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", diff --git a/play-life-web/package.json b/play-life-web/package.json index 434ec3d..5701a4c 100644 --- a/play-life-web/package.json +++ b/play-life-web/package.json @@ -1,6 +1,6 @@ { "name": "play-life-web", - "version": "3.28.0", + "version": "3.28.2", "type": "module", "scripts": { "dev": "vite", diff --git a/play-life-web/src/components/TaskDetail.jsx b/play-life-web/src/components/TaskDetail.jsx index 76eabd1..1ef6da1 100644 --- a/play-life-web/src/components/TaskDetail.jsx +++ b/play-life-web/src/components/TaskDetail.jsx @@ -395,21 +395,13 @@ function TaskDetail({ taskId, onClose, onRefresh, onTaskCompleted, onNavigate }) const data = await response.json() setTaskDetail(data) - // Загружаем информацию о связанном желании, если есть - if (data.task.wishlist_id) { - try { - const wishlistResponse = await authFetch(`/api/wishlist/${data.task.wishlist_id}`) - if (wishlistResponse.ok) { - const wishlistData = await wishlistResponse.json() - setWishlistInfo({ - id: wishlistData.id, - name: wishlistData.name, - unlocked: wishlistData.unlocked || false - }) - } - } catch (err) { - console.error('Error loading wishlist info:', err) - } + // Используем информацию о wishlist из ответа API + if (data.wishlist_info) { + setWishlistInfo({ + id: data.wishlist_info.id, + name: data.wishlist_info.name, + unlocked: data.wishlist_info.unlocked || false + }) } else { setWishlistInfo(null) }