From db3b2640a84761f224d86f8d926e9b55cfd36876 Mon Sep 17 00:00:00 2001 From: poignatov Date: Tue, 13 Jan 2026 18:22:02 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A0=D0=B5=D1=84=D0=B0=D0=BA=D1=82=D0=BE?= =?UTF-8?q?=D1=80=D0=B8=D0=BD=D0=B3=20=D1=82=D0=B5=D1=81=D1=82=D0=BE=D0=B2?= =?UTF-8?q?:=20=D0=B8=D0=BD=D1=82=D0=B5=D0=B3=D1=80=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D1=8F=20=D1=81=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=87=D0=B0=D0=BC?= =?UTF-8?q?=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- VERSION | 2 +- play-life-backend/main.go | 383 ++++++++++++++---- .../022_refactor_configs_to_tasks.sql | 49 +++ play-life-web/package.json | 2 +- play-life-web/src/App.jsx | 86 +--- play-life-web/src/components/AddConfig.css | 222 ---------- play-life-web/src/components/AddConfig.jsx | 346 ---------------- ...ConfigSelection.css => DictionaryList.css} | 360 +++++----------- .../src/components/DictionaryList.jsx | 176 ++++++++ play-life-web/src/components/Profile.jsx | 30 ++ play-life-web/src/components/TaskForm.css | 96 +++++ play-life-web/src/components/TaskForm.jsx | 179 ++++++-- play-life-web/src/components/TaskList.css | 98 +++++ play-life-web/src/components/TaskList.jsx | 101 ++++- .../src/components/TestConfigSelection.jsx | 286 ------------- play-life-web/src/components/TestWords.jsx | 26 +- play-life-web/src/components/WordList.jsx | 2 +- 17 files changed, 1166 insertions(+), 1278 deletions(-) create mode 100644 play-life-backend/migrations/022_refactor_configs_to_tasks.sql delete mode 100644 play-life-web/src/components/AddConfig.css delete mode 100644 play-life-web/src/components/AddConfig.jsx rename play-life-web/src/components/{TestConfigSelection.css => DictionaryList.css} (50%) create mode 100644 play-life-web/src/components/DictionaryList.jsx delete mode 100644 play-life-web/src/components/TestConfigSelection.jsx diff --git a/VERSION b/VERSION index 36435ac..afad818 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -3.10.8 +3.11.0 diff --git a/play-life-backend/main.go b/play-life-backend/main.go index e94462e..bbff993 100644 --- a/play-life-backend/main.go +++ b/play-life-backend/main.go @@ -72,19 +72,15 @@ type TestProgressRequest struct { } type Config struct { - ID int `json:"id"` - Name string `json:"name"` - WordsCount int `json:"words_count"` - MaxCards *int `json:"max_cards,omitempty"` - TryMessage string `json:"try_message"` + ID int `json:"id"` + WordsCount int `json:"words_count"` + MaxCards *int `json:"max_cards,omitempty"` } type ConfigRequest struct { - Name string `json:"name"` - WordsCount int `json:"words_count"` - MaxCards *int `json:"max_cards,omitempty"` - TryMessage string `json:"try_message"` - DictionaryIDs []int `json:"dictionary_ids,omitempty"` + WordsCount int `json:"words_count"` + MaxCards *int `json:"max_cards,omitempty"` + DictionaryIDs []int `json:"dictionary_ids,omitempty"` } type Dictionary struct { @@ -214,6 +210,7 @@ type Task struct { RepetitionPeriod *string `json:"repetition_period,omitempty"` RepetitionDate *string `json:"repetition_date,omitempty"` WishlistID *int `json:"wishlist_id,omitempty"` + ConfigID *int `json:"config_id,omitempty"` // Дополнительные поля для списка задач (без omitempty чтобы всегда передавались) ProjectNames []string `json:"project_names"` SubtasksCount int `json:"subtasks_count"` @@ -234,9 +231,13 @@ type Subtask struct { } type TaskDetail struct { - Task Task `json:"task"` - Rewards []Reward `json:"rewards"` - Subtasks []Subtask `json:"subtasks"` + Task Task `json:"task"` + Rewards []Reward `json:"rewards"` + Subtasks []Subtask `json:"subtasks"` + // Test-specific fields (only present if task has config_id) + WordsCount *int `json:"words_count,omitempty"` + MaxCards *int `json:"max_cards,omitempty"` + DictionaryIDs []int `json:"dictionary_ids,omitempty"` } type RewardRequest struct { @@ -262,6 +263,11 @@ type TaskRequest struct { WishlistID *int `json:"wishlist_id,omitempty"` Rewards []RewardRequest `json:"rewards,omitempty"` Subtasks []SubtaskRequest `json:"subtasks,omitempty"` + // Test-specific fields + IsTest bool `json:"is_test,omitempty"` + WordsCount *int `json:"words_count,omitempty"` + MaxCards *int `json:"max_cards,omitempty"` + DictionaryIDs []int `json:"dictionary_ids,omitempty"` } type CompleteTaskRequest struct { @@ -1671,47 +1677,9 @@ func (a *App) updateTestProgressHandler(w http.ResponseWriter, r *http.Request) return } - // If config_id is provided, send webhook with try_message - if req.ConfigID != nil { - configID := *req.ConfigID - - // Use mutex to prevent duplicate webhook sends - a.webhookMutex.Lock() - lastTime, exists := a.lastWebhookTime[configID] - now := time.Now() - - // Only send webhook if it hasn't been sent in the last 5 seconds for this config - shouldSend := !exists || now.Sub(lastTime) > 5*time.Second - - if shouldSend { - a.lastWebhookTime[configID] = now - } - a.webhookMutex.Unlock() - - if !shouldSend { - log.Printf("Webhook skipped for config_id %d (sent recently)", configID) - } else { - var tryMessage sql.NullString - err := a.DB.QueryRow("SELECT try_message FROM configs WHERE id = $1", configID).Scan(&tryMessage) - if err == nil && tryMessage.Valid && tryMessage.String != "" { - // Process message directly (backend always runs together with frontend) - _, err := a.processMessage(tryMessage.String, &userID) - if err != nil { - log.Printf("Error processing message: %v", err) - // Remove from map on error so it can be retried - a.webhookMutex.Lock() - delete(a.lastWebhookTime, configID) - a.webhookMutex.Unlock() - } else { - log.Printf("Message processed successfully for config_id %d", configID) - } - } else if err != nil && err != sql.ErrNoRows { - log.Printf("Error fetching config: %v", err) - } else if err == nil && (!tryMessage.Valid || tryMessage.String == "") { - log.Printf("Webhook skipped for config_id %d (try_message is empty)", configID) - } - } - } + // Note: Reward message is now sent via completeTaskHandler when the test task is automatically completed. + // The config_id is kept in the request for potential future use, but we no longer send messages here + // to avoid duplicate messages (one from test completion, one from task completion). w.Header().Set("Content-Type", "application/json") w.Header().Set("Access-Control-Allow-Origin", "*") @@ -1734,7 +1702,7 @@ func (a *App) getConfigsHandler(w http.ResponseWriter, r *http.Request) { } query := ` - SELECT id, name, words_count, max_cards, try_message + SELECT id, words_count, max_cards FROM configs WHERE user_id = $1 ORDER BY id @@ -1753,10 +1721,8 @@ func (a *App) getConfigsHandler(w http.ResponseWriter, r *http.Request) { var maxCards sql.NullInt64 err := rows.Scan( &config.ID, - &config.Name, &config.WordsCount, &maxCards, - &config.TryMessage, ) if err != nil { sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) @@ -2076,7 +2042,7 @@ func (a *App) getTestConfigsAndDictionariesHandler(w http.ResponseWriter, r *htt // Get configs configsQuery := ` - SELECT id, name, words_count, max_cards, try_message + SELECT id, words_count, max_cards FROM configs WHERE user_id = $1 ORDER BY id @@ -2095,10 +2061,8 @@ func (a *App) getTestConfigsAndDictionariesHandler(w http.ResponseWriter, r *htt var maxCards sql.NullInt64 err := configsRows.Scan( &config.ID, - &config.Name, &config.WordsCount, &maxCards, - &config.TryMessage, ) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) @@ -2175,10 +2139,6 @@ func (a *App) addConfigHandler(w http.ResponseWriter, r *http.Request) { return } - if req.Name == "" { - sendErrorWithCORS(w, "Имя обязательно для заполнения", http.StatusBadRequest) - return - } if req.WordsCount <= 0 { sendErrorWithCORS(w, "Количество слов должно быть больше 0", http.StatusBadRequest) return @@ -2193,10 +2153,10 @@ func (a *App) addConfigHandler(w http.ResponseWriter, r *http.Request) { var id int err = tx.QueryRow(` - INSERT INTO configs (name, words_count, max_cards, try_message, user_id) - VALUES ($1, $2, $3, $4, $5) + INSERT INTO configs (words_count, max_cards, user_id) + VALUES ($1, $2, $3) RETURNING id - `, req.Name, req.WordsCount, req.MaxCards, req.TryMessage, userID).Scan(&id) + `, req.WordsCount, req.MaxCards, userID).Scan(&id) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) @@ -2268,10 +2228,6 @@ func (a *App) updateConfigHandler(w http.ResponseWriter, r *http.Request) { return } - if req.Name == "" { - sendErrorWithCORS(w, "Имя обязательно для заполнения", http.StatusBadRequest) - return - } if req.WordsCount <= 0 { sendErrorWithCORS(w, "Количество слов должно быть больше 0", http.StatusBadRequest) return @@ -2286,9 +2242,9 @@ func (a *App) updateConfigHandler(w http.ResponseWriter, r *http.Request) { result, err := tx.Exec(` UPDATE configs - SET name = $1, words_count = $2, max_cards = $3, try_message = $4 - WHERE id = $5 - `, req.Name, req.WordsCount, req.MaxCards, req.TryMessage, configID) + SET words_count = $1, max_cards = $2 + WHERE id = $3 + `, req.WordsCount, req.MaxCards, configID) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) @@ -2840,6 +2796,12 @@ func (a *App) initAuthDB() error { // Не возвращаем ошибку, чтобы приложение могло запуститься } + // Apply migration 022: Refactor configs to link with tasks + if err := a.applyMigration022(); err != nil { + log.Printf("Warning: Failed to apply migration 022: %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()") @@ -3109,6 +3071,51 @@ func (a *App) applyMigration021() error { return nil } +// applyMigration022 применяет миграцию 022_refactor_configs_to_tasks.sql +func (a *App) applyMigration022() error { + log.Printf("Applying migration 022: Refactor configs to link with tasks") + + // Проверяем, существует ли уже поле config_id в tasks + var exists bool + err := a.DB.QueryRow(` + SELECT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_name = 'tasks' + AND column_name = 'config_id' + ) + `).Scan(&exists) + + if err != nil { + return fmt.Errorf("failed to check config_id column existence: %w", err) + } + + if exists { + log.Printf("Migration 022 already applied (config_id column exists), skipping") + return nil + } + + // Читаем SQL из файла миграции + migrationPath := "migrations/022_refactor_configs_to_tasks.sql" + if _, err := os.Stat(migrationPath); os.IsNotExist(err) { + // Пробуем альтернативный путь (в Docker) + migrationPath = "/migrations/022_refactor_configs_to_tasks.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 022: %w", err) + } + + log.Printf("Migration 022 applied successfully") + return nil +} + func (a *App) initPlayLifeDB() error { // Создаем таблицу projects createProjectsTable := ` @@ -6734,6 +6741,7 @@ func (a *App) getTasksHandler(w http.ResponseWriter, r *http.Request) { t.repetition_date, t.progression_base, t.wishlist_id, + t.config_id, COALESCE(( SELECT COUNT(*) FROM tasks st @@ -6778,6 +6786,7 @@ func (a *App) getTasksHandler(w http.ResponseWriter, r *http.Request) { var repetitionDate sql.NullString var progressionBase sql.NullFloat64 var wishlistID sql.NullInt64 + var configID sql.NullInt64 var projectNames pq.StringArray var subtaskProjectNames pq.StringArray @@ -6791,6 +6800,7 @@ func (a *App) getTasksHandler(w http.ResponseWriter, r *http.Request) { &repetitionDate, &progressionBase, &wishlistID, + &configID, &task.SubtasksCount, &projectNames, &subtaskProjectNames, @@ -6822,6 +6832,10 @@ func (a *App) getTasksHandler(w http.ResponseWriter, r *http.Request) { wishlistIDInt := int(wishlistID.Int64) task.WishlistID = &wishlistIDInt } + if configID.Valid { + configIDInt := int(configID.Int64) + task.ConfigID = &configIDInt + } // Объединяем проекты из основной задачи и подзадач allProjects := make(map[string]bool) @@ -6879,6 +6893,7 @@ func (a *App) getTaskDetailHandler(w http.ResponseWriter, r *http.Request) { var repetitionPeriod sql.NullString var repetitionDate sql.NullString var wishlistID sql.NullInt64 + var configID sql.NullInt64 // Сначала получаем значение как строку напрямую, чтобы избежать проблем с NULL var repetitionPeriodStr string @@ -6887,11 +6902,12 @@ func (a *App) getTaskDetailHandler(w http.ResponseWriter, r *http.Request) { 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, COALESCE(repetition_date, '') as repetition_date, - wishlist_id + wishlist_id, + config_id FROM tasks WHERE id = $1 AND user_id = $2 AND deleted = FALSE `, taskID, userID).Scan( - &task.ID, &task.Name, &task.Completed, &lastCompletedAt, &nextShowAt, &rewardMessage, &progressionBase, &repetitionPeriodStr, &repetitionDateStr, &wishlistID, + &task.ID, &task.Name, &task.Completed, &lastCompletedAt, &nextShowAt, &rewardMessage, &progressionBase, &repetitionPeriodStr, &repetitionDateStr, &wishlistID, &configID, ) log.Printf("Scanned repetition_period for task %d: String='%s', repetition_date='%s'", taskID, repetitionPeriodStr, repetitionDateStr) @@ -6944,6 +6960,10 @@ func (a *App) getTaskDetailHandler(w http.ResponseWriter, r *http.Request) { wishlistIDInt := int(wishlistID.Int64) task.WishlistID = &wishlistIDInt } + if configID.Valid { + configIDInt := int(configID.Int64) + task.ConfigID = &configIDInt + } // Получаем награды основной задачи rewards := make([]Reward, 0) @@ -7044,6 +7064,47 @@ func (a *App) getTaskDetailHandler(w http.ResponseWriter, r *http.Request) { Subtasks: subtasks, } + // Если задача - тест (есть config_id), загружаем данные конфигурации + if configID.Valid { + var wordsCount int + var maxCards sql.NullInt64 + err := a.DB.QueryRow(` + SELECT words_count, max_cards + FROM configs + WHERE id = $1 + `, configID.Int64).Scan(&wordsCount, &maxCards) + + if err == nil { + response.WordsCount = &wordsCount + if maxCards.Valid { + maxCardsInt := int(maxCards.Int64) + response.MaxCards = &maxCardsInt + } + + // Загружаем связанные словари + dictRows, err := a.DB.Query(` + SELECT dictionary_id + FROM config_dictionaries + WHERE config_id = $1 + `, configID.Int64) + if err == nil { + defer dictRows.Close() + dictionaryIDs := make([]int, 0) + for dictRows.Next() { + var dictID int + if err := dictRows.Scan(&dictID); err == nil { + dictionaryIDs = append(dictionaryIDs, dictID) + } + } + if len(dictionaryIDs) > 0 { + response.DictionaryIDs = dictionaryIDs + } + } + } else { + log.Printf("Error loading config for task %d: %v", taskID, err) + } + } + w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(response) } @@ -7365,6 +7426,66 @@ func (a *App) createTaskHandler(w http.ResponseWriter, r *http.Request) { } } + // Если это тест, создаем конфигурацию + if req.IsTest { + // Валидация: для теста должны быть указаны words_count и хотя бы один словарь + if req.WordsCount == nil || *req.WordsCount < 1 { + sendErrorWithCORS(w, "Words count is required for test tasks and must be at least 1", http.StatusBadRequest) + return + } + if len(req.DictionaryIDs) == 0 { + sendErrorWithCORS(w, "At least one dictionary is required for test tasks", http.StatusBadRequest) + return + } + + // Создаем конфигурацию теста + var configID int + if req.MaxCards != nil { + err = tx.QueryRow(` + INSERT INTO configs (user_id, words_count, max_cards) + VALUES ($1, $2, $3) + RETURNING id + `, userID, *req.WordsCount, *req.MaxCards).Scan(&configID) + } else { + err = tx.QueryRow(` + INSERT INTO configs (user_id, words_count) + VALUES ($1, $2) + RETURNING id + `, userID, *req.WordsCount).Scan(&configID) + } + + if err != nil { + log.Printf("Error creating config: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Error creating config: %v", err), http.StatusInternalServerError) + return + } + + // Связываем конфигурацию со словарями + for _, dictID := range req.DictionaryIDs { + _, err = tx.Exec(` + INSERT INTO config_dictionaries (config_id, dictionary_id) + VALUES ($1, $2) + `, configID, dictID) + if err != nil { + log.Printf("Error linking dictionary %d to config: %v", dictID, err) + sendErrorWithCORS(w, fmt.Sprintf("Error linking dictionary to config: %v", err), http.StatusInternalServerError) + return + } + } + + // Обновляем задачу, привязывая config_id + _, err = tx.Exec(` + UPDATE tasks SET config_id = $1 WHERE id = $2 + `, configID, taskID) + if err != nil { + log.Printf("Error linking config to task: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Error linking config to task: %v", err), http.StatusInternalServerError) + return + } + + log.Printf("Created test config %d for task %d", configID, taskID) + } + // Коммитим транзакцию if err := tx.Commit(); err != nil { log.Printf("Error committing transaction: %v", err) @@ -7771,6 +7892,116 @@ func (a *App) updateTaskHandler(w http.ResponseWriter, r *http.Request) { } } + // Получаем текущий config_id задачи + var currentConfigID sql.NullInt64 + err = tx.QueryRow("SELECT config_id FROM tasks WHERE id = $1", taskID).Scan(¤tConfigID) + if err != nil { + log.Printf("Error getting current config_id: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Error getting task config: %v", err), http.StatusInternalServerError) + return + } + + // Обработка конфигурации теста + if req.IsTest { + // Валидация: для теста должны быть указаны words_count и хотя бы один словарь + if req.WordsCount == nil || *req.WordsCount < 1 { + sendErrorWithCORS(w, "Words count is required for test tasks and must be at least 1", http.StatusBadRequest) + return + } + if len(req.DictionaryIDs) == 0 { + sendErrorWithCORS(w, "At least one dictionary is required for test tasks", http.StatusBadRequest) + return + } + + if currentConfigID.Valid { + // Обновляем существующую конфигурацию + if req.MaxCards != nil { + _, err = tx.Exec(` + UPDATE configs SET words_count = $1, max_cards = $2 WHERE id = $3 + `, *req.WordsCount, *req.MaxCards, currentConfigID.Int64) + } else { + _, err = tx.Exec(` + UPDATE configs SET words_count = $1, max_cards = NULL WHERE id = $2 + `, *req.WordsCount, currentConfigID.Int64) + } + if err != nil { + log.Printf("Error updating config: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Error updating config: %v", err), http.StatusInternalServerError) + return + } + + // Обновляем связи со словарями + _, err = tx.Exec("DELETE FROM config_dictionaries WHERE config_id = $1", currentConfigID.Int64) + if err != nil { + log.Printf("Error deleting config dictionaries: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Error updating config dictionaries: %v", err), http.StatusInternalServerError) + return + } + + for _, dictID := range req.DictionaryIDs { + _, err = tx.Exec(` + INSERT INTO config_dictionaries (config_id, dictionary_id) VALUES ($1, $2) + `, currentConfigID.Int64, dictID) + if err != nil { + log.Printf("Error linking dictionary %d to config: %v", dictID, err) + sendErrorWithCORS(w, fmt.Sprintf("Error linking dictionary to config: %v", err), http.StatusInternalServerError) + return + } + } + } else { + // Создаем новую конфигурацию для существующей задачи + var newConfigID int + if req.MaxCards != nil { + err = tx.QueryRow(` + INSERT INTO configs (user_id, words_count, max_cards) VALUES ($1, $2, $3) RETURNING id + `, userID, *req.WordsCount, *req.MaxCards).Scan(&newConfigID) + } else { + err = tx.QueryRow(` + INSERT INTO configs (user_id, words_count) VALUES ($1, $2) RETURNING id + `, userID, *req.WordsCount).Scan(&newConfigID) + } + if err != nil { + log.Printf("Error creating config: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Error creating config: %v", err), http.StatusInternalServerError) + return + } + + for _, dictID := range req.DictionaryIDs { + _, err = tx.Exec(` + INSERT INTO config_dictionaries (config_id, dictionary_id) VALUES ($1, $2) + `, newConfigID, dictID) + if err != nil { + log.Printf("Error linking dictionary %d to config: %v", dictID, err) + sendErrorWithCORS(w, fmt.Sprintf("Error linking dictionary to config: %v", err), http.StatusInternalServerError) + return + } + } + + _, err = tx.Exec("UPDATE tasks SET config_id = $1 WHERE id = $2", newConfigID, taskID) + if err != nil { + log.Printf("Error linking config to task: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Error linking config to task: %v", err), http.StatusInternalServerError) + return + } + } + } else if currentConfigID.Valid { + // Задача перестала быть тестом - удаляем конфигурацию + _, err = tx.Exec("DELETE FROM config_dictionaries WHERE config_id = $1", currentConfigID.Int64) + if err != nil { + log.Printf("Error deleting config dictionaries: %v", err) + } + _, err = tx.Exec("DELETE FROM configs WHERE id = $1", currentConfigID.Int64) + if err != nil { + log.Printf("Error deleting config: %v", err) + } + _, err = tx.Exec("UPDATE tasks SET config_id = NULL WHERE id = $1", taskID) + if err != nil { + log.Printf("Error unlinking config from task: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Error unlinking config from task: %v", err), http.StatusInternalServerError) + return + } + } + // Коммитим транзакцию if err := tx.Commit(); err != nil { log.Printf("Error committing transaction: %v", err) diff --git a/play-life-backend/migrations/022_refactor_configs_to_tasks.sql b/play-life-backend/migrations/022_refactor_configs_to_tasks.sql new file mode 100644 index 0000000..6187187 --- /dev/null +++ b/play-life-backend/migrations/022_refactor_configs_to_tasks.sql @@ -0,0 +1,49 @@ +-- Migration: Refactor configs to link via tasks.config_id +-- This migration adds config_id to tasks table and migrates existing configs to tasks +-- After migration: configs only contain words_count, max_cards (name and try_message removed) + +-- ============================================ +-- Step 1: Add config_id to tasks +-- ============================================ +ALTER TABLE tasks +ADD COLUMN IF NOT EXISTS config_id INTEGER REFERENCES configs(id) ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS idx_tasks_config_id ON tasks(config_id); + +-- Unique index: only one task per config +CREATE UNIQUE INDEX IF NOT EXISTS idx_tasks_config_id_unique +ON tasks(config_id) WHERE config_id IS NOT NULL AND deleted = FALSE; + +COMMENT ON COLUMN tasks.config_id IS 'Link to test config. NULL if task is not a test.'; + +-- ============================================ +-- Step 2: Migrate existing configs to tasks +-- Create a task for each config that doesn't have one yet +-- ============================================ +INSERT INTO tasks (user_id, name, reward_message, repetition_period, repetition_date, config_id) +SELECT + c.user_id, + c.name, -- Config name -> Task name + c.try_message, -- try_message -> reward_message + '0 day'::INTERVAL, -- repetition_period = 0 (infinite task) + '0 week', -- repetition_date = 0 (infinite task) + c.id -- Link to config +FROM configs c +WHERE c.name IS NOT NULL -- Only configs with names +AND NOT EXISTS ( + SELECT 1 FROM tasks t WHERE t.config_id = c.id AND t.deleted = FALSE +); + +-- ============================================ +-- Step 3: Remove name and try_message from configs +-- These are now stored in the linked task +-- ============================================ +ALTER TABLE configs DROP COLUMN IF EXISTS name; +ALTER TABLE configs DROP COLUMN IF EXISTS try_message; + +-- ============================================ +-- Comments for documentation +-- ============================================ +COMMENT ON TABLE configs IS 'Test configurations (words_count, max_cards, dictionary associations). Linked to tasks via tasks.config_id.'; + + diff --git a/play-life-web/package.json b/play-life-web/package.json index 5aed656..abe0cb3 100644 --- a/play-life-web/package.json +++ b/play-life-web/package.json @@ -1,6 +1,6 @@ { "name": "play-life-web", - "version": "3.10.8", + "version": "3.11.0", "type": "module", "scripts": { "dev": "vite", diff --git a/play-life-web/src/App.jsx b/play-life-web/src/App.jsx index 7753776..8140369 100644 --- a/play-life-web/src/App.jsx +++ b/play-life-web/src/App.jsx @@ -4,8 +4,7 @@ import FullStatistics from './components/FullStatistics' import ProjectPriorityManager from './components/ProjectPriorityManager' import WordList from './components/WordList' import AddWords from './components/AddWords' -import TestConfigSelection from './components/TestConfigSelection' -import AddConfig from './components/AddConfig' +import DictionaryList from './components/DictionaryList' import TestWords from './components/TestWords' import Profile from './components/Profile' import TaskList from './components/TaskList' @@ -24,8 +23,8 @@ const CURRENT_WEEK_API_URL = '/playlife-feed' const FULL_STATISTICS_API_URL = '/d2dc349a-0d13-49b2-a8f0-1ab094bfba9b' // Определяем основные табы (без крестика) и глубокие табы (с крестиком) -const mainTabs = ['current', 'test-config', 'tasks', 'wishlist', 'profile'] -const deepTabs = ['add-words', 'add-config', 'test', 'task-form', 'wishlist-form', 'wishlist-detail', 'words', 'todoist-integration', 'telegram-integration', 'full', 'priorities'] +const mainTabs = ['current', 'tasks', 'wishlist', 'profile'] +const deepTabs = ['add-words', 'test', 'task-form', 'wishlist-form', 'wishlist-detail', 'words', 'dictionaries', 'todoist-integration', 'telegram-integration', 'full', 'priorities'] function AppContent() { const { authFetch, isAuthenticated, loading: authLoading } = useAuth() @@ -51,8 +50,7 @@ function AppContent() { full: false, words: false, 'add-words': false, - 'test-config': false, - 'add-config': false, + dictionaries: false, test: false, tasks: false, 'task-form': false, @@ -71,8 +69,7 @@ function AppContent() { full: false, words: false, 'add-words': false, - 'test-config': false, - 'add-config': false, + dictionaries: false, test: false, tasks: false, 'task-form': false, @@ -113,7 +110,7 @@ function AppContent() { // Состояние для кнопки Refresh (если она есть) const [isRefreshing, setIsRefreshing] = useState(false) const [prioritiesRefreshTrigger, setPrioritiesRefreshTrigger] = useState(0) - const [testConfigRefreshTrigger, setTestConfigRefreshTrigger] = useState(0) + const [dictionariesRefreshTrigger, setDictionariesRefreshTrigger] = useState(0) const [wordsRefreshTrigger, setWordsRefreshTrigger] = useState(0) const [wishlistRefreshTrigger, setWishlistRefreshTrigger] = useState(0) @@ -128,7 +125,7 @@ function AppContent() { // Проверяем URL только для глубоких табов const urlParams = new URLSearchParams(window.location.search) const tabFromUrl = urlParams.get('tab') - const validTabs = ['current', 'priorities', 'full', 'words', 'add-words', 'test-config', 'add-config', 'test', 'tasks', 'task-form', 'wishlist', 'wishlist-form', 'wishlist-detail', 'profile', 'todoist-integration', 'telegram-integration'] + const validTabs = ['current', 'priorities', 'full', 'words', 'add-words', 'dictionaries', 'test', 'tasks', 'task-form', 'wishlist', 'wishlist-form', 'wishlist-detail', 'profile', 'todoist-integration', 'telegram-integration'] if (tabFromUrl && validTabs.includes(tabFromUrl) && deepTabs.includes(tabFromUrl)) { // Если в URL есть глубокий таб, восстанавливаем его @@ -381,8 +378,7 @@ function AppContent() { full: false, words: false, 'add-words': false, - 'test-config': false, - 'add-config': false, + dictionaries: false, test: false, tasks: false, 'task-form': false, @@ -452,17 +448,17 @@ function AppContent() { // Возврат на таб - фоновая загрузка setPrioritiesRefreshTrigger(prev => prev + 1) } - } else if (tab === 'test-config') { - const isInitialized = tabsInitializedRef.current['test-config'] + } else if (tab === 'dictionaries') { + const isInitialized = tabsInitializedRef.current['dictionaries'] if (!isInitialized) { // Первая загрузка таба - setTestConfigRefreshTrigger(prev => prev + 1) - tabsInitializedRef.current['test-config'] = true - setTabsInitialized(prev => ({ ...prev, 'test-config': true })) + setDictionariesRefreshTrigger(prev => prev + 1) + tabsInitializedRef.current['dictionaries'] = true + setTabsInitialized(prev => ({ ...prev, 'dictionaries': true })) } else if (isBackground) { // Возврат на таб - фоновая загрузка - setTestConfigRefreshTrigger(prev => prev + 1) + setDictionariesRefreshTrigger(prev => prev + 1) } } else if (tab === 'tasks') { const hasCache = cacheRef.current.tasks !== null @@ -502,7 +498,7 @@ function AppContent() { // Обработчик кнопки "назад" в браузере (только для глубоких табов) useEffect(() => { const handlePopState = (event) => { - const validTabs = ['current', 'priorities', 'full', 'words', 'add-words', 'test-config', 'add-config', 'test', 'tasks', 'task-form', 'wishlist', 'wishlist-form', 'wishlist-detail', 'profile', 'todoist-integration', 'telegram-integration'] + const validTabs = ['current', 'priorities', 'full', 'words', 'add-words', 'dictionaries', 'test', 'tasks', 'task-form', 'wishlist', 'wishlist-form', 'wishlist-detail', 'profile', 'todoist-integration', 'telegram-integration'] // Проверяем state текущей записи истории (куда мы вернулись) if (event.state && event.state.tab) { @@ -617,15 +613,7 @@ function AppContent() { const isCurrentTabMain = mainTabs.includes(activeTab) const isNewTabMain = mainTabs.includes(tab) - // Сбрасываем tabParams при переходе с add-config на другой таб - if (activeTab === 'add-config' && tab !== 'add-config') { - setTabParams({}) - if (isNewTabMain) { - clearUrl() - } else if (isNewTabDeep) { - updateUrl(tab, {}, activeTab) - } - } else { + { // Для task-form и wishlist-form явно удаляем параметры, только если нет никаких параметров // task-form может иметь taskId (редактирование) или wishlistId (создание из желания) const isTaskFormWithNoParams = tab === 'task-form' && params.taskId === undefined && params.wishlistId === undefined @@ -723,7 +711,7 @@ function AppContent() { }, [activeTab]) // Определяем, нужно ли скрывать нижнюю панель (для fullscreen экранов) - const isFullscreenTab = activeTab === 'test' || activeTab === 'add-words' || activeTab === 'add-config' || activeTab === 'task-form' || activeTab === 'wishlist-form' || activeTab === 'wishlist-detail' || activeTab === 'todoist-integration' || activeTab === 'telegram-integration' || activeTab === 'full' || activeTab === 'priorities' + const isFullscreenTab = activeTab === 'test' || activeTab === 'add-words' || activeTab === 'task-form' || activeTab === 'wishlist-form' || activeTab === 'wishlist-detail' || activeTab === 'todoist-integration' || activeTab === 'telegram-integration' || activeTab === 'full' || activeTab === 'priorities' || activeTab === 'dictionaries' // Определяем отступы для контейнера const getContainerPadding = () => { @@ -818,21 +806,11 @@ function AppContent() { )} - {loadedTabs['test-config'] && ( -
- + -
- )} - - {loadedTabs['add-config'] && ( -
-
)} @@ -844,6 +822,7 @@ function AppContent() { wordCount={tabParams.wordCount} configId={tabParams.configId} maxCards={tabParams.maxCards} + taskId={tabParams.taskId} /> )} @@ -948,27 +927,6 @@ function AppContent() {
)} - -

Конфигурация теста

- -
-
- - setName(e.target.value)} - placeholder="Название конфига" - required - /> -
- -
- -