# План: Изменить сортировку заблокированных желаний по времени разблокировки

## Цель
Изменить сортировку желаний:
1. Разблокированные - по цене от меньшего к большему
2. Заблокированные без целей-задач - по сроку разблокировки (максимальное время среди проектов)
3. Заблокированные с целями-задачами - по сроку разблокировки (максимальное время среди проектов)

## Статус реализации

**Уже реализовано:**
- ✅ `calculateProjectUnlockWeeks` - функция расчета недель разблокировки
- ✅ `calculateLockedSortValue` - использует `calculateProjectUnlockWeeks` и возвращает недели
- ✅ `getProjectMedian` - упрощенная версия без fallback (используется как есть)

**Требуется реализовать:**
- ⏳ Создать миграцию для `projects_median_mv` (миграции нет, но используется в коде)
- ⏳ В `getWishlistHandler`: заменить `calculateUnlockedSortValue` на прямую сортировку по цене для разблокированных
- ⏳ В `getWishlistHandler`: разделить заблокированные на группы (с задачами/без задач) и сортировать каждую группу
- ⏳ В `getBoardItemsHandler`: заменить `calculateUnlockedSortValue` на прямую сортировку по цене для разблокированных
- ⏳ В `getBoardItemsHandler`: разделить заблокированные на группы (с задачами/без задач) и сортировать каждую группу

## Изменения

### 1. Создать миграцию для projects_median_mv

**Статус:** `getProjectMedian` уже использует `projects_median_mv`, но миграции для неё нет в списке миграций. Нужно создать миграцию.

**Файл:** `play-life-backend/migrations/000007_add_projects_median_mv.up.sql`

Убедиться, что materialized view включает `user_id`:
```sql
CREATE MATERIALIZED VIEW projects_median_mv AS
SELECT
    p.id AS project_id,
    p.user_id,
    PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY normalized_total_score) AS median_score
FROM (
    SELECT 
        project_id, 
        normalized_total_score,
        report_year,
        report_week,
        ROW_NUMBER() OVER (PARTITION BY project_id ORDER BY report_year DESC, report_week DESC) as rn
    FROM weekly_report_mv
    WHERE 
        (report_year < EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER)
        OR (report_year = EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER 
            AND report_week < EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER)
) sub
JOIN projects p ON p.id = sub.project_id
WHERE rn <= 12 AND p.deleted = FALSE
GROUP BY p.id, p.user_id
WITH DATA;

CREATE INDEX idx_projects_median_mv_project_id ON projects_median_mv(project_id);
CREATE INDEX idx_projects_median_mv_user_id ON projects_median_mv(user_id);
```

**Файл:** `play-life-backend/migrations/000007_add_projects_median_mv.down.sql`

```sql
DROP MATERIALIZED VIEW IF EXISTS projects_median_mv;
```

### 2. Изменить calculateLockedSortValue для расчета времени

**Файл:** `play-life-backend/main.go` (строки 12488-12561)

**Статус:** Функция уже реализована и использует `calculateProjectUnlockWeeks`. Проверить, что логика соответствует требованиям:
- Учитывает только условия типа `project_points`
- Использует правильного владельца условия (`conditionOwnerID`)
- Возвращает максимальное количество недель среди всех условий проектов
- Возвращает 999999.0 если нет условий по проектам или все выполнены

**Текущая реализация уже корректна**, изменения не требуются.

**Важно:** 
- Функция уже использует `calculateProjectUnlockWeeks` для расчета (уже реализовано)
- Функция НЕ должна учитывать задачи, только проекты. Разделение на группы с задачами и без задач будет в сортировке.
- Функция уже правильно обрабатывает владельца условия через `conditionOwnerID` (не использует `userID` напрямую)

### 3. Обновить сортировку в getWishlistHandler

**Файл:** `play-life-backend/main.go` (строки 9933-9951)

**Текущее состояние:**
- Разблокированные: используют `calculateUnlockedSortValue` (сумма баллов) - **нужно заменить на цену**
- Заблокированные: сортируются по `calculateLockedSortValue` (недели) - **нужно разделить на группы**

**Изменить:**
1. Разблокированные: сортировка по цене от меньшего к большему (заменить `calculateUnlockedSortValue`)
2. Заблокированные: разделить на группы (с задачами/без задач) и сортировать каждую группу по времени

```go
// Сортируем разблокированные по цене от меньшего к большему
// ЗАМЕНА: было calculateUnlockedSortValue, стало прямая сортировка по цене
sort.Slice(unlocked, func(i, j int) bool {
    priceI := 0.0
    priceJ := 0.0
    if unlocked[i].Price != nil {
        priceI = *unlocked[i].Price
    }
    if unlocked[j].Price != nil {
        priceJ = *unlocked[j].Price
    }
    if priceI == priceJ {
        return unlocked[i].ID < unlocked[j].ID
    }
    return priceI < priceJ  // Сортировка по цене от меньшего к большему (заменяет calculateUnlockedSortValue)
})

// Разделяем заблокированные на группы
lockedWithoutTasks := []WishlistItem{}
lockedWithTasks := []WishlistItem{}

for _, item := range locked {
    hasUncompletedTasks := false
    for _, cond := range item.UnlockConditions {
        if cond.Type == "task_completion" && (cond.TaskCompleted == nil || !*cond.TaskCompleted) {
            hasUncompletedTasks = true
            break
        }
    }
    if hasUncompletedTasks {
        lockedWithTasks = append(lockedWithTasks, item)
    } else {
        lockedWithoutTasks = append(lockedWithoutTasks, item)
    }
}

// Сортируем каждую группу по времени разблокировки
sort.Slice(lockedWithoutTasks, func(i, j int) bool {
    valueI := a.calculateLockedSortValue(lockedWithoutTasks[i], userID)
    valueJ := a.calculateLockedSortValue(lockedWithoutTasks[j], userID)
    if valueI == valueJ {
        return lockedWithoutTasks[i].ID < lockedWithoutTasks[j].ID
    }
    return valueI < valueJ
})

sort.Slice(lockedWithTasks, func(i, j int) bool {
    valueI := a.calculateLockedSortValue(lockedWithTasks[i], userID)
    valueJ := a.calculateLockedSortValue(lockedWithTasks[j], userID)
    if valueI == valueJ {
        return lockedWithTasks[i].ID < lockedWithTasks[j].ID
    }
    return valueI < valueJ
})

// Объединяем: сначала без задач, потом с задачами
locked = append(lockedWithoutTasks, lockedWithTasks...)
```

### 4. Обновить сортировку в getBoardItemsHandler

**Файл:** `play-life-backend/main.go` (строки 12222-12240)

**Текущее состояние:**
- Разблокированные: используют `calculateUnlockedSortValue` (сумма баллов) - **нужно заменить на цену**
- Заблокированные: сортируются по `calculateLockedSortValue` (недели) - **нужно разделить на группы**

**Изменить аналогично getWishlistHandler:**
1. Разблокированные: сортировка по цене от меньшего к большему (заменить `calculateUnlockedSortValue`)
2. Заблокированные: разделить на группы (с задачами/без задач) и сортировать каждую группу по времени

```go
// Сортируем разблокированные по цене от меньшего к большему
// ЗАМЕНА: было calculateUnlockedSortValue, стало прямая сортировка по цене
sort.Slice(unlocked, func(i, j int) bool {
    priceI := 0.0
    priceJ := 0.0
    if unlocked[i].Price != nil {
        priceI = *unlocked[i].Price
    }
    if unlocked[j].Price != nil {
        priceJ = *unlocked[j].Price
    }
    if priceI == priceJ {
        return unlocked[i].ID < unlocked[j].ID
    }
    return priceI < priceJ
})

// РАЗДЕЛЕНИЕ НА ГРУППЫ: Заблокированные с задачами и без задач
// ЗАМЕНА: было просто sort.Slice(locked, ...), стало разделение на группы
lockedWithoutTasks := []WishlistItem{}
lockedWithTasks := []WishlistItem{}

for _, item := range locked {
    hasUncompletedTasks := false
    for _, cond := range item.UnlockConditions {
        if cond.Type == "task_completion" && (cond.TaskCompleted == nil || !*cond.TaskCompleted) {
            hasUncompletedTasks = true
            break
        }
    }
    if hasUncompletedTasks {
        lockedWithTasks = append(lockedWithTasks, item)
    } else {
        lockedWithoutTasks = append(lockedWithoutTasks, item)
    }
}

// Сортируем каждую группу по времени разблокировки
sort.Slice(lockedWithoutTasks, func(i, j int) bool {
    valueI := a.calculateLockedSortValue(lockedWithoutTasks[i], userID)
    valueJ := a.calculateLockedSortValue(lockedWithoutTasks[j], userID)
    if valueI == valueJ {
        return lockedWithoutTasks[i].ID < lockedWithoutTasks[j].ID
    }
    return valueI < valueJ
})

sort.Slice(lockedWithTasks, func(i, j int) bool {
    valueI := a.calculateLockedSortValue(lockedWithTasks[i], userID)
    valueJ := a.calculateLockedSortValue(lockedWithTasks[j], userID)
    if valueI == valueJ {
        return lockedWithTasks[i].ID < lockedWithTasks[j].ID
    }
    return valueI < valueJ
})

// Объединяем: сначала без задач, потом с задачами
locked = append(lockedWithoutTasks, lockedWithTasks...)
```

## Итоговый порядок элементов

1. **Разблокированные** - отсортированы по цене от меньшего к большему
2. **Заблокированные без целей-задач** - отсортированы по максимальному времени разблокировки (среди всех проектов) от меньшего к большему
3. **Заблокированные с целями-задачами** - отсортированы по максимальному времени разблокировки (среди всех проектов) от меньшего к большему

## Обработка краевых случаев

- **Если медиана проекта = 0 или отсутствует**: `calculateProjectUnlockWeeks` возвращает 99999, что обрабатывается в `calculateLockedSortValue` (не учитывается в maxWeeks, если >= 99999)
- **Если нет условий**: возвращать 999999.0 (отсутствие условий = все условия выполнены)
- **Если все условия выполнены**: возвращать 999999.0
- **Если цена не указана (NULL)**: считать как 0.0
- **Если нет условий по проектам** (только задачи или нет условий): возвращать 999999.0

## Зависимости

- `projects_median_mv` должна существовать (проверить наличие миграции или создать при необходимости)
- Функция `getProjectMedian` уже реализована (упрощенная версия без fallback)
- Функция `calculateProjectUnlockWeeks` уже реализована и используется в `calculateLockedSortValue`

## Финальный шаг: Перезапуск приложения

**После выполнения всех изменений:**

Выполнить команду для перезапуска фронтенда и бэкенда:
```bash
./run.sh
```

Это пересоберет и перезапустит:
- Backend сервер (с пересборкой)
- Frontend приложение (с пересборкой)
- База данных
