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() {
)}
-