3.28.2: Оптимизация загрузки деталей задачи
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m44s
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m44s
This commit is contained in:
@@ -25,7 +25,7 @@ alwaysApply: true
|
|||||||
|
|
||||||
### 3. Проанализируй git diff
|
### 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. Закоммить изменения
|
### 4. Закоммить изменения
|
||||||
|
|
||||||
|
|||||||
@@ -243,10 +243,17 @@ type Subtask struct {
|
|||||||
Rewards []Reward `json:"rewards"`
|
Rewards []Reward `json:"rewards"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type WishlistInfo struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Unlocked bool `json:"unlocked"`
|
||||||
|
}
|
||||||
|
|
||||||
type TaskDetail struct {
|
type TaskDetail struct {
|
||||||
Task Task `json:"task"`
|
Task Task `json:"task"`
|
||||||
Rewards []Reward `json:"rewards"`
|
Rewards []Reward `json:"rewards"`
|
||||||
Subtasks []Subtask `json:"subtasks"`
|
Subtasks []Subtask `json:"subtasks"`
|
||||||
|
WishlistInfo *WishlistInfo `json:"wishlist_info,omitempty"`
|
||||||
// Test-specific fields (only present if task has config_id)
|
// Test-specific fields (only present if task has config_id)
|
||||||
WordsCount *int `json:"words_count,omitempty"`
|
WordsCount *int `json:"words_count,omitempty"`
|
||||||
MaxCards *int `json:"max_cards,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)
|
// 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()")
|
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
|
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 {
|
func (a *App) initPlayLifeDB() error {
|
||||||
// Создаем таблицу projects
|
// Создаем таблицу projects
|
||||||
createProjectsTable := `
|
createProjectsTable := `
|
||||||
@@ -7331,6 +7440,9 @@ func (a *App) getTaskDetailHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
log.Printf("Error querying subtasks: %v", err)
|
log.Printf("Error querying subtasks: %v", err)
|
||||||
} else {
|
} else {
|
||||||
defer subtaskRows.Close()
|
defer subtaskRows.Close()
|
||||||
|
subtaskMap := make(map[int]*Subtask)
|
||||||
|
subtaskIDs := make([]int, 0)
|
||||||
|
|
||||||
for subtaskRows.Next() {
|
for subtaskRows.Next() {
|
||||||
var subtaskTask Task
|
var subtaskTask Task
|
||||||
var subtaskRewardMessage sql.NullString
|
var subtaskRewardMessage sql.NullString
|
||||||
@@ -7356,33 +7468,56 @@ func (a *App) getTaskDetailHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
subtaskTask.LastCompletedAt = &subtaskLastCompletedAt.String
|
subtaskTask.LastCompletedAt = &subtaskLastCompletedAt.String
|
||||||
}
|
}
|
||||||
|
|
||||||
// Получаем награды подзадачи
|
subtaskIDs = append(subtaskIDs, subtaskTask.ID)
|
||||||
subtaskRewards := make([]Reward, 0)
|
subtask := Subtask{
|
||||||
subtaskRewardRows, err := a.DB.Query(`
|
Task: subtaskTask,
|
||||||
SELECT rc.id, rc.position, p.name AS project_name, rc.value, rc.use_progression
|
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
|
FROM reward_configs rc
|
||||||
JOIN projects p ON rc.project_id = p.id
|
JOIN projects p ON rc.project_id = p.id
|
||||||
WHERE rc.task_id = $1
|
WHERE rc.task_id = ANY(ARRAY[%s])
|
||||||
ORDER BY rc.position
|
ORDER BY rc.task_id, rc.position
|
||||||
`, subtaskTask.ID)
|
`, 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() {
|
for subtaskRewardRows.Next() {
|
||||||
|
var taskID int
|
||||||
var reward Reward
|
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 {
|
if err != nil {
|
||||||
log.Printf("Error scanning subtask reward: %v", err)
|
log.Printf("Error scanning subtask reward: %v", err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
subtaskRewards = append(subtaskRewards, reward)
|
if subtask, exists := subtaskMap[taskID]; exists {
|
||||||
|
subtask.Rewards = append(subtask.Rewards, reward)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
subtaskRewardRows.Close()
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
subtasks = append(subtasks, Subtask{
|
// Преобразуем map в slice, сохраняя порядок по ID
|
||||||
Task: subtaskTask,
|
for _, id := range subtaskIDs {
|
||||||
Rewards: subtaskRewards,
|
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,
|
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), загружаем данные конфигурации
|
// Если задача - тест (есть config_id), загружаем данные конфигурации
|
||||||
if configID.Valid {
|
if configID.Valid {
|
||||||
var wordsCount int
|
var wordsCount int
|
||||||
|
|||||||
14
play-life-backend/migrations/028_optimize_task_queries.sql
Normal file
14
play-life-backend/migrations/028_optimize_task_queries.sql
Normal file
@@ -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.';
|
||||||
25
play-life-backend/migrations/029_add_covering_indexes.sql
Normal file
25
play-life-backend/migrations/029_add_covering_indexes.sql
Normal file
@@ -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.';
|
||||||
4
play-life-web/package-lock.json
generated
4
play-life-web/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "play-life-web",
|
"name": "play-life-web",
|
||||||
"version": "3.19.0",
|
"version": "3.28.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "play-life-web",
|
"name": "play-life-web",
|
||||||
"version": "3.19.0",
|
"version": "3.28.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "play-life-web",
|
"name": "play-life-web",
|
||||||
"version": "3.28.0",
|
"version": "3.28.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -395,21 +395,13 @@ function TaskDetail({ taskId, onClose, onRefresh, onTaskCompleted, onNavigate })
|
|||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
setTaskDetail(data)
|
setTaskDetail(data)
|
||||||
|
|
||||||
// Загружаем информацию о связанном желании, если есть
|
// Используем информацию о wishlist из ответа API
|
||||||
if (data.task.wishlist_id) {
|
if (data.wishlist_info) {
|
||||||
try {
|
setWishlistInfo({
|
||||||
const wishlistResponse = await authFetch(`/api/wishlist/${data.task.wishlist_id}`)
|
id: data.wishlist_info.id,
|
||||||
if (wishlistResponse.ok) {
|
name: data.wishlist_info.name,
|
||||||
const wishlistData = await wishlistResponse.json()
|
unlocked: data.wishlist_info.unlocked || false
|
||||||
setWishlistInfo({
|
})
|
||||||
id: wishlistData.id,
|
|
||||||
name: wishlistData.name,
|
|
||||||
unlocked: wishlistData.unlocked || false
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error loading wishlist info:', err)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
setWishlistInfo(null)
|
setWishlistInfo(null)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user