diff --git a/.cursor/plans/изменить_сортировку_заблокированных_желаний_по_времени_разблокировки_4987d56a.plan.md b/.cursor/plans/изменить_сортировку_заблокированных_желаний_по_времени_разблокировки_4987d56a.plan.md new file mode 100644 index 0000000..0174406 --- /dev/null +++ b/.cursor/plans/изменить_сортировку_заблокированных_желаний_по_времени_разблокировки_4987d56a.plan.md @@ -0,0 +1,260 @@ +# План: Изменить сортировку заблокированных желаний по времени разблокировки + +## Цель +Изменить сортировку желаний: +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 приложение (с пересборкой) +- База данных diff --git a/.cursor/plans/создать_общие_функции_расчета_и_форматирования_срока_разблокировки_8a3f4b2c.plan.md b/.cursor/plans/создать_общие_функции_расчета_и_форматирования_срока_разблокировки_8a3f4b2c.plan.md new file mode 100644 index 0000000..fc97fae --- /dev/null +++ b/.cursor/plans/создать_общие_функции_расчета_и_форматирования_срока_разблокировки_8a3f4b2c.plan.md @@ -0,0 +1,392 @@ +# План: Создать общие функции расчета и форматирования срока разблокировки + +## Цель + +Создать универсальные функции для расчета и форматирования срока разблокировки проекта, которые будут использоваться везде где необходимо считать остаточный срок. + +## Изменения + +### 1. Создать функцию расчета срока разблокировки (бэкенд) + +**Файл:** `play-life-backend/main.go` + +Создать функцию `calculateProjectUnlockWeeks`: + +```go +// calculateProjectUnlockWeeks рассчитывает срок разблокировки проекта в неделях +// projectID - ID проекта +// requiredPoints - необходимое количество баллов +// startDate - дата начала подсчета (может быть nil - за всё время) +// userID - ID пользователя (владельца условия) +// Возвращает количество недель (float64): +// - > 0: условие не выполнено, возвращает количество недель +// - 0: условие уже выполнено (remaining <= 0) +// - 99999: медиана отсутствует или равна 0 (нельзя рассчитать) +func (a *App) calculateProjectUnlockWeeks(projectID int, requiredPoints float64, startDate sql.NullTime, userID int) float64 { + // 1. Получаем текущие баллы от startDate + currentPoints, err := a.calculateProjectPointsFromDate(projectID, startDate, userID) + if err != nil { + log.Printf("Error calculating project points for project %d, user %d: %v", projectID, userID, err) + return 99999 // Ошибка расчета - возвращаем 99999 + } + + // 2. Вычисляем остаток + remaining := requiredPoints - currentPoints + if remaining <= 0 { + // Условие уже выполнено + return 0 + } + + // 3. Получаем медиану проекта + median, err := a.getProjectMedian(projectID) + if err != nil || median <= 0 { + // Если медиана отсутствует или равна 0, возвращаем 99999 (нельзя рассчитать) + // Это нормальная ситуация, не логируем + return 99999 + } + + // 4. Рассчитываем недели + weeks := remaining / median + return weeks +} +``` + +**Примечание:** Функция возвращает: + +- `0`: условие уже выполнено (remaining <= 0) +- `> 0 && < 99999`: количество недель до выполнения условия +- `99999`: медиана отсутствует или равна 0 (нельзя рассчитать) или ошибка расчета + +```` + +### 2. Создать функцию форматирования срока (бэкенд) + +**Файл:** `play-life-backend/main.go` + +Создать функцию `formatWeeksText`: + +```go +// formatWeeksText форматирует количество недель в текстовый формат +// weeks - количество недель (float64) +// Возвращает строку: "2 недели", "<1 недели", "5 недель", "∞ недель" и т.д. +func formatWeeksText(weeks float64) string { + // Если weeks == 0, условие уже выполнено - не показываем срок + if weeks == 0 { + return "" + } + + // Если weeks >= 99999, это означает что медиана отсутствует или нельзя рассчитать + if weeks >= 99999 { + return "∞ недель" + } + + if weeks < 0 { + return "" + } + + if weeks < 1 { + return "<1 недели" + } + + weeksRounded := math.Round(weeks) + weeksInt := int(weeksRounded) + + // Правильное склонение для русского языка + var weekWord string + lastDigit := weeksInt % 10 + lastTwoDigits := weeksInt % 100 + + if lastTwoDigits >= 11 && lastTwoDigits <= 14 { + weekWord = "недель" + } else if lastDigit == 1 { + weekWord = "неделя" + } else if lastDigit >= 2 && lastDigit <= 4 { + weekWord = "недели" + } else { + weekWord = "недель" + } + + return fmt.Sprintf("%d %s", weeksInt, weekWord) +} +``` + +**Примечание:** + +- Форматирование на бэкенде, так как сортировка происходит на бэкенде. Фронтенд получает уже отформатированную строку. +- При `weeks == 0` (условие выполнено) возвращается пустая строка (не показываем срок) +- При `weeks >= 99999` (медиана отсутствует, нельзя рассчитать или ошибка расчета) возвращается "∞ недель" + +### 3. Использовать функции в calculateLockedSortValue + +**Файл:** `play-life-backend/main.go` (строки 12314-12337) + +Обновить функцию для использования `calculateProjectUnlockWeeks`: + +```go +func (a *App) calculateLockedSortValue(item WishlistItem, userID int) float64 { + // Если нет условий, возвращаем большое значение (отсутствие условий = все выполнены) + if len(item.UnlockConditions) == 0 { + return 999999.0 + } + + maxWeeks := 0.0 + hasProjectConditions := false + + for _, condition := range item.UnlockConditions { + if condition.Type == "project_points" { + hasProjectConditions = true + if condition.RequiredPoints != nil { + var startDate sql.NullTime + if condition.StartDate != nil { + date, err := time.Parse("2006-01-02", *condition.StartDate) + if err == nil { + startDate = sql.NullTime{Time: date, Valid: true} + } + } + + // ВАЖНО: Используем владельца условия из condition.UserID + // Если condition.UserID есть - это владелец условия + // Если нет - получаем владельца желания из БД (для старых условий) + // НЕ используем текущего пользователя (userID), так как условие может принадлежать другому пользователю + conditionOwnerID := 0 + if condition.UserID != nil { + conditionOwnerID = *condition.UserID + } else { + // Если нет владельца условия, получаем владельца желания из БД + var itemOwnerID int + err := a.DB.QueryRow(`SELECT user_id FROM wishlist_items WHERE id = $1`, item.ID).Scan(&itemOwnerID) + if err != nil { + log.Printf("Error getting wishlist item owner for item %d: %v", item.ID, err) + continue // Пропускаем условие, если не можем получить владельца + } + conditionOwnerID = itemOwnerID + } + + // Получаем projectID из условия + if condition.ProjectID != nil { + weeks := a.calculateProjectUnlockWeeks( + *condition.ProjectID, + *condition.RequiredPoints, + startDate, + conditionOwnerID, // Владелец условия, а не текущий пользователь + ) + // weeks > 0 && < 99999 означает, что условие еще не выполнено и расчет успешен + // weeks == 0 означает условие выполнено + // weeks == 99999 означает медиана отсутствует (нельзя рассчитать) или ошибка расчета + if weeks > 0 && weeks < 99999 { + if weeks > maxWeeks { + maxWeeks = weeks + } + } + } + } + } + } + + // Если были условия по проектам, но все выполнены (maxWeeks = 0) + if hasProjectConditions && maxWeeks == 0.0 { + return 999999.0 + } + + // Если не было условий по проектам (только задачи или нет условий) + if !hasProjectConditions { + return 999999.0 + } + + return maxWeeks +} +``` + +### 4. Использовать функции в API endpoint для расчета недель + +**Файл:** `play-life-backend/main.go` + +Обновить endpoint `/api/wishlist/calculate-weeks` (из плана "добавить расчет недель в форму"): + +**Важно:** Использовать владельца условия, а не текущего пользователя! + +```go +func (a *App) calculateWeeksHandler(w http.ResponseWriter, r *http.Request) { + // ... валидация и получение параметров ... + + // Определяем владельца условия: + // 1. Если передан condition_user_id в запросе - используем его (для существующего условия) + // 2. Иначе используем текущего пользователя (для нового условия) + conditionOwnerID := userID // userID из контекста (текущий пользователь) + if req.ConditionUserID != nil && *req.ConditionUserID > 0 { + conditionOwnerID = *req.ConditionUserID + } + + var startDate sql.NullTime + if req.StartDate != "" { + date, err := time.Parse("2006-01-02", req.StartDate) + if err == nil { + startDate = sql.NullTime{Time: date, Valid: true} + } + } + + // Используем владельца условия, а не текущего пользователя + weeks := a.calculateProjectUnlockWeeks(req.ProjectID, req.RequiredPoints, startDate, conditionOwnerID) + + response := map[string]interface{}{ + "weeks_text": formatWeeksText(weeks), // Отформатированная строка для отображения + } + + // weeks используется только для сортировки на бэкенде, на клиент не отправляется + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} +``` + +**Структура запроса:** + +```go +type CalculateWeeksRequest struct { + ProjectID int `json:"project_id"` + RequiredPoints float64 `json:"required_points"` + StartDate string `json:"start_date,omitempty"` + ConditionUserID *int `json:"condition_user_id,omitempty"` // Владелец условия (если условие существует) +} +``` + +### 5. Добавить weeks_text в UnlockConditionDisplay + +**Файл:** `play-life-backend/main.go` + +Добавить поле `WeeksText *string` в структуру `UnlockConditionDisplay`: + +```go +type UnlockConditionDisplay struct { + // ... существующие поля ... + WeeksText *string `json:"weeks_text,omitempty"` // Отформатированный текст срока разблокировки +} +``` + +При загрузке условий типа `project_points` рассчитывать и форматировать срок: + +```go +if condition.Type == "project_points" && condition.RequiredPoints != nil && condition.ProjectID != nil { + var startDate sql.NullTime + if condition.StartDate != nil { + date, err := time.Parse("2006-01-02", *condition.StartDate) + if err == nil { + startDate = sql.NullTime{Time: date, Valid: true} + } + } + + // ВАЖНО: Используем владельца условия из condition.UserID, а не текущего пользователя + // Если condition.UserID есть - это владелец условия + // Если нет - используем владельца желания (itemOwnerID), но НЕ текущего пользователя (userID) + conditionOwnerID := itemOwnerID // Владелец желания как fallback + if condition.UserID != nil { + conditionOwnerID = *condition.UserID // Владелец условия (приоритет) + } + + weeks := a.calculateProjectUnlockWeeks( + *condition.ProjectID, + *condition.RequiredPoints, + startDate, + conditionOwnerID, // Владелец условия, а не текущий пользователь + ) + + // Форматируем всегда (при weeks == 0 вернет пустую строку, при weeks >= 99999 вернет "∞ недель") + weeksText := formatWeeksText(weeks) + condition.WeeksText = &weeksText +} +``` + +**Важно:** + +- `condition.UserID` - это владелец условия (из `wishlist_conditions.user_id`) +- `itemOwnerID` - это владелец желания (fallback для старых условий) +- `userID` (текущий пользователь) НЕ используется, так как условие может принадлежать другому пользователю + +### 6. Использовать weeks_text на фронтенде + +**Файл:** `play-life-web/src/components/WishlistDetail.jsx` + +Использовать готовый `weeks_text` из условия (приходит уже отформатированным из API): + +```javascript +// В renderUnlockConditions: +{progress.remaining > 0 && condition.weeks_text && ( + + Осталось: {Math.round(progress.remaining)} ({condition.weeks_text}) + +)} +``` + +**Файл:** `play-life-web/src/components/WishlistForm.jsx` + +Использовать `weeks_text` из ответа API для отображения недель в форме редактирования условия. Форматирование уже выполнено на бэкенде. + +### 7. Обновить загрузку медианы в условиях (опционально) + +**Файл:** `play-life-backend/main.go` + +При загрузке условий типа `project_points` медиана не нужна отдельно, так как `calculateProjectUnlockWeeks` сама получит её и вернет уже отформатированный `weeks_text`. + +## Места использования функций + +1. **calculateProjectUnlockWeeks** (бэкенд): + + - `calculateLockedSortValue` - для сортировки заблокированных желаний (использует числовое значение) + - `calculateWeeksHandler` - API endpoint для расчета недель (использует для расчета, но на клиент отправляется только отформатированная строка) + - При загрузке условий для расчета `weeks_text` (используется внутри, на клиент не отправляется) + - Любые другие места, где нужно рассчитать срок разблокировки + +2. **formatWeeksText** (бэкенд): + + - При загрузке условий в `UnlockConditionDisplay.WeeksText` (отправляется на клиент для отображения) + - В API endpoint `/api/wishlist/calculate-weeks` (отправляется на клиент для отображения в форме) + - Форматирование на бэкенде, так как сортировка происходит на бэкенде по числовому значению `weeks` + +## Выявленные и исправленные проблемы + +1. **Проблема с userID в calculateLockedSortValue**: + + - **Проблема**: Использовался текущий пользователь (`userID`), но условие может принадлежать другому пользователю + - **Исправление**: Используется `conditionOwnerID` из `condition.UserID` (владелец условия). Если `condition.UserID` отсутствует, условие пропускается (некорректное состояние) + +2. **Обработка отсутствия медианы**: + + - **Решение**: При отсутствии медианы возвращается `99999` (нельзя рассчитать). В `formatWeeksText` это значение преобразуется в "∞ недель". Такие условия не учитываются при сортировке по времени разблокировки (проверка `weeks > 0 && weeks < 99999`) + +3. **Форматирование и передача данных**: + + - **Решение**: Форматирование на бэкенде, так как сортировка происходит на бэкенде по числовому значению `weeks` + - Числовое значение `weeks` используется только на бэкенде для сортировки, на клиент не отправляется + - На клиент отправляется только отформатированная строка `weeks_text` для отображения + - Фронтенд просто отображает готовую строку без дополнительного форматирования + - Это исключает дублирование логики и обеспечивает единообразие форматирования + +4. **Использование правильного userID (владельца условия)**: + + - **Проблема**: В функцию `calculateProjectUnlockWeeks` может передаваться текущий пользователь вместо владельца условия + - **Решение**: + - В `calculateLockedSortValue`: используется `condition.UserID` (владелец условия) + - В `calculateWeeksHandler`: используется `condition_user_id` из запроса (если передан) или текущий пользователь (для нового условия) + - При загрузке условий: используется `condition.UserID` или `itemOwnerID` (владелец желания), но НЕ текущий пользователь + - **Важно**: Условие может принадлежать другому пользователю (на общих досках), поэтому нужно использовать именно владельца условия + +## Зависимости + +- Функция `getProjectMedian` должна быть создана (из плана сортировки) +- Функция `calculateProjectPointsFromDate` уже существует + +## Финальный шаг: Перезапуск приложения + +**После выполнения всех изменений:** + +Выполнить команду для перезапуска фронтенда и бэкенда: + +```bash +./run.sh +``` + +Это пересоберет и перезапустит: + +- Backend сервер (с пересборкой) +- Frontend приложение (с пересборкой) +- База данных \ No newline at end of file diff --git a/VERSION b/VERSION index a84947d..6016e8a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -4.5.0 +4.6.0 diff --git a/play-life-backend/main.go b/play-life-backend/main.go index 0ebbf17..ead377e 100644 --- a/play-life-backend/main.go +++ b/play-life-backend/main.go @@ -351,18 +351,19 @@ type LinkedTask struct { } type WishlistItem struct { - ID int `json:"id"` - Name string `json:"name"` - Price *float64 `json:"price,omitempty"` - ImageURL *string `json:"image_url,omitempty"` - Link *string `json:"link,omitempty"` - Unlocked bool `json:"unlocked"` - Completed bool `json:"completed"` - FirstLockedCondition *UnlockConditionDisplay `json:"first_locked_condition,omitempty"` - MoreLockedConditions int `json:"more_locked_conditions,omitempty"` - UnlockConditions []UnlockConditionDisplay `json:"unlock_conditions,omitempty"` - LinkedTask *LinkedTask `json:"linked_task,omitempty"` - TasksCount int `json:"tasks_count,omitempty"` // Количество задач для этого желания + ID int `json:"id"` + Name string `json:"name"` + Price *float64 `json:"price,omitempty"` + ImageURL *string `json:"image_url,omitempty"` + Link *string `json:"link,omitempty"` + Unlocked bool `json:"unlocked"` + Completed bool `json:"completed"` + FirstLockedCondition *UnlockConditionDisplay `json:"first_locked_condition,omitempty"` + MoreLockedConditions int `json:"more_locked_conditions,omitempty"` + LockedConditionsCount int `json:"locked_conditions_count,omitempty"` // Общее количество заблокированных условий + UnlockConditions []UnlockConditionDisplay `json:"unlock_conditions,omitempty"` + LinkedTask *LinkedTask `json:"linked_task,omitempty"` + TasksCount int `json:"tasks_count,omitempty"` // Количество задач для этого желания } type UnlockConditionDisplay struct { @@ -381,6 +382,8 @@ type UnlockConditionDisplay struct { // Персональные цели UserID *int `json:"user_id,omitempty"` // ID пользователя для персональных целей UserName *string `json:"user_name,omitempty"` // Имя пользователя для персональных целей + // Срок разблокировки + WeeksText *string `json:"weeks_text,omitempty"` // Отформатированный текст срока разблокировки } type WishlistRequest struct { @@ -2812,7 +2815,7 @@ func (a *App) startWeeklyGoalsScheduler() { // Cron выражение: "0 6 * * 1" означает: минута=0, час=6, любой день месяца, любой месяц, понедельник (1) _, err = c.AddFunc("0 6 * * 1", func() { now := time.Now().In(loc) - log.Printf("Scheduled task: Refreshing weekly report MV and setting up weekly goals (timezone: %s, local time: %s)", timezoneStr, now.Format("2006-01-02 15:04:05 MST")) + log.Printf("Scheduled task: Refreshing materialized views and setting up weekly goals (timezone: %s, local time: %s)", timezoneStr, now.Format("2006-01-02 15:04:05 MST")) // Сначала обновляем MV (чтобы в ней были данные прошлой недели) _, err := a.DB.Exec("REFRESH MATERIALIZED VIEW weekly_report_mv") @@ -2822,6 +2825,14 @@ func (a *App) startWeeklyGoalsScheduler() { log.Printf("Materialized view refreshed successfully") } + // Обновляем projects_median_mv после обновления weekly_report_mv + _, err = a.DB.Exec("REFRESH MATERIALIZED VIEW projects_median_mv") + if err != nil { + log.Printf("Error refreshing projects_median_mv: %v", err) + } else { + log.Printf("Projects median materialized view refreshed successfully") + } + // Затем настраиваем цели на новую неделю if err := a.setupWeeklyGoals(); err != nil { log.Printf("Error in scheduled weekly goals setup: %v", err) @@ -3713,6 +3724,7 @@ func main() { protected.HandleFunc("/api/wishlist/completed", app.getWishlistCompletedHandler).Methods("GET", "OPTIONS") protected.HandleFunc("/api/wishlist/metadata", app.extractLinkMetadataHandler).Methods("POST", "OPTIONS") protected.HandleFunc("/api/wishlist/proxy-image", app.proxyImageHandler).Methods("GET", "OPTIONS") + protected.HandleFunc("/api/wishlist/calculate-weeks", app.calculateWeeksHandler).Methods("POST", "OPTIONS") // Wishlist Boards (ВАЖНО: должны быть ПЕРЕД /api/wishlist/{id} чтобы избежать конфликта роутов!) protected.HandleFunc("/api/wishlist/boards", app.getBoardsHandler).Methods("GET", "OPTIONS") @@ -9165,6 +9177,104 @@ func (a *App) calculateProjectPointsFromDate( return totalScore, nil } +// getProjectMedian получает медиану проекта из materialized view projects_median_mv +// Если медиана отсутствует, возвращает ошибку +func (a *App) getProjectMedian(projectID int) (float64, error) { + var median float64 + err := a.DB.QueryRow(` + SELECT median_score + FROM projects_median_mv + WHERE project_id = $1 + `, projectID).Scan(&median) + if err != nil { + if err == sql.ErrNoRows { + return 0, fmt.Errorf("median not found for project %d", projectID) + } + return 0, err + } + return median, nil +} + +// calculateProjectUnlockWeeks рассчитывает срок разблокировки проекта в неделях +// projectID - ID проекта +// requiredPoints - необходимое количество баллов +// startDate - дата начала подсчета (может быть nil - за всё время) +// userID - ID пользователя (владельца условия) +// Возвращает количество недель (float64): +// - > 0: условие не выполнено, возвращает количество недель +// - 0: условие уже выполнено (remaining <= 0) +// - 99999: медиана отсутствует или равна 0 (нельзя рассчитать) или ошибка расчета +func (a *App) calculateProjectUnlockWeeks(projectID int, requiredPoints float64, startDate sql.NullTime, userID int) float64 { + // 1. Получаем текущие баллы от startDate + currentPoints, err := a.calculateProjectPointsFromDate(projectID, startDate, userID) + if err != nil { + log.Printf("Error calculating project points for project %d, user %d: %v", projectID, userID, err) + return 99999 // Ошибка расчета - возвращаем 99999 + } + + // 2. Вычисляем остаток + remaining := requiredPoints - currentPoints + if remaining <= 0 { + // Условие уже выполнено + return 0 + } + + // 3. Получаем медиану проекта + median, err := a.getProjectMedian(projectID) + if err != nil || median <= 0 { + // Если медиана отсутствует или равна 0, возвращаем 99999 (нельзя рассчитать) + // Это нормальная ситуация, не логируем + return 99999 + } + + // 4. Рассчитываем недели + weeks := remaining / median + return weeks +} + +// formatWeeksText форматирует количество недель в текстовый формат +// weeks - количество недель (float64) +// Возвращает строку: "2 недели", "<1 недели", "5 недель", "∞ недель" и т.д. +func formatWeeksText(weeks float64) string { + // Если weeks == 0, условие уже выполнено - не показываем срок + if weeks == 0 { + return "" + } + + // Если weeks >= 99999, это означает что медиана отсутствует или нельзя рассчитать + if weeks >= 99999 { + return "∞ недель" + } + + if weeks < 0 { + return "" + } + + if weeks < 1 { + return "<1 недели" + } + + weeksRounded := math.Round(weeks) + weeksInt := int(weeksRounded) + + // Правильное склонение для русского языка + var weekWord string + lastDigit := weeksInt % 10 + lastTwoDigits := weeksInt % 100 + + if lastTwoDigits >= 11 && lastTwoDigits <= 14 { + weekWord = "недель" + } else if lastDigit == 1 { + weekWord = "неделя" + } else if lastDigit >= 2 && lastDigit <= 4 { + weekWord = "недели" + } else { + weekWord = "недель" + } + + return fmt.Sprintf("%d %s", weeksInt, weekWord) +} + // checkWishlistUnlock проверяет ВСЕ условия для желания // Все условия должны выполняться (AND логика) func (a *App) checkWishlistUnlock(itemID int, userID int) (bool, error) { @@ -9277,6 +9387,149 @@ func (a *App) checkWishlistUnlock(itemID int, userID int) (bool, error) { return allConditionsMet, nil } +// isConditionLocked определяет, заблокировано ли условие +func isConditionLocked(cond UnlockConditionDisplay) bool { + if cond.Type == "task_completion" { + return cond.TaskCompleted == nil || !*cond.TaskCompleted + } else if cond.Type == "project_points" { + return cond.CurrentPoints == nil || cond.RequiredPoints == nil || *cond.CurrentPoints < *cond.RequiredPoints + } + return false +} + +// getConditionUnlockWeeks возвращает количество недель для разблокировки условия +// Используется для сортировки заблокированных условий по баллам +func (a *App) getConditionUnlockWeeks(cond UnlockConditionDisplay, userID int) float64 { + if cond.Type != "project_points" { + return 0 + } + if cond.ProjectID == nil || cond.RequiredPoints == nil { + return 99999.0 + } + + var startDate sql.NullTime + if cond.StartDate != nil { + date, err := time.Parse("2006-01-02", *cond.StartDate) + if err == nil { + startDate = sql.NullTime{Time: date, Valid: true} + } + } + + conditionOwnerID := userID + if cond.UserID != nil { + conditionOwnerID = *cond.UserID + } + + return a.calculateProjectUnlockWeeks(*cond.ProjectID, *cond.RequiredPoints, startDate, conditionOwnerID) +} + +// sortUnlockConditions сортирует условия в следующем порядке: +// 1. Заблокированные задачи (по алфавиту) +// 2. Заблокированные баллы (по сроку от меньшего к большему) +// 3. Разблокированные задачи (по алфавиту) +// 4. Разблокированные баллы (по алфавиту) +func (a *App) sortUnlockConditions(conditions []UnlockConditionDisplay, userID int) { + sort.Slice(conditions, func(i, j int) bool { + condI := conditions[i] + condJ := conditions[j] + + lockedI := isConditionLocked(condI) + lockedJ := isConditionLocked(condJ) + + // 1. Заблокированные идут перед разблокированными + if lockedI != lockedJ { + return lockedI // lockedI == true идет первым + } + + // Если оба заблокированы или оба разблокированы, сортируем по типу + if lockedI { + // Заблокированные: задачи идут перед баллами + if condI.Type == "task_completion" && condJ.Type == "project_points" { + return true + } + if condI.Type == "project_points" && condJ.Type == "task_completion" { + return false + } + + // Если оба одного типа + if condI.Type == "task_completion" { + // Заблокированные задачи: по алфавиту + taskNameI := "" + taskNameJ := "" + if condI.TaskName != nil { + taskNameI = *condI.TaskName + } + if condJ.TaskName != nil { + taskNameJ = *condJ.TaskName + } + if taskNameI != taskNameJ { + return taskNameI < taskNameJ + } + return condI.ID < condJ.ID + } else { + // Заблокированные баллы: по сроку от меньшего к большему + weeksI := a.getConditionUnlockWeeks(condI, userID) + weeksJ := a.getConditionUnlockWeeks(condJ, userID) + if weeksI != weeksJ { + return weeksI < weeksJ + } + // Если сроки равны, сортируем по алфавиту по названию проекта + projectNameI := "" + projectNameJ := "" + if condI.ProjectName != nil { + projectNameI = *condI.ProjectName + } + if condJ.ProjectName != nil { + projectNameJ = *condJ.ProjectName + } + if projectNameI != projectNameJ { + return projectNameI < projectNameJ + } + return condI.ID < condJ.ID + } + } else { + // Разблокированные: задачи идут перед баллами + if condI.Type == "task_completion" && condJ.Type == "project_points" { + return true + } + if condI.Type == "project_points" && condJ.Type == "task_completion" { + return false + } + + // Если оба одного типа, сортируем по алфавиту + if condI.Type == "task_completion" { + // Разблокированные задачи: по алфавиту + taskNameI := "" + taskNameJ := "" + if condI.TaskName != nil { + taskNameI = *condI.TaskName + } + if condJ.TaskName != nil { + taskNameJ = *condJ.TaskName + } + if taskNameI != taskNameJ { + return taskNameI < taskNameJ + } + return condI.ID < condJ.ID + } else { + // Разблокированные баллы: по алфавиту + projectNameI := "" + projectNameJ := "" + if condI.ProjectName != nil { + projectNameI = *condI.ProjectName + } + if condJ.ProjectName != nil { + projectNameJ = *condJ.ProjectName + } + if projectNameI != projectNameJ { + return projectNameI < projectNameJ + } + return condI.ID < condJ.ID + } + } + }) +} + // getWishlistItemsWithConditions загружает желания с их условиями func (a *App) getWishlistItemsWithConditions(userID int, includeCompleted bool) ([]WishlistItem, error) { query := ` @@ -9424,6 +9677,9 @@ func (a *App) getWishlistItemsWithConditions(userID int, includeCompleted bool) } item.Unlocked = unlocked + // Сортируем условия в нужном порядке + a.sortUnlockConditions(item.UnlockConditions, userID) + // Определяем первое заблокированное условие и количество остальных, а также рассчитываем прогресс if !unlocked && !item.Completed { lockedCount := 0 @@ -9489,6 +9745,7 @@ func (a *App) getWishlistItemsWithConditions(userID int, includeCompleted bool) if firstLocked != nil { item.FirstLockedCondition = firstLocked item.MoreLockedConditions = lockedCount - 1 + item.LockedConditionsCount = lockedCount } } else { // Даже если желание разблокировано, рассчитываем прогресс для всех условий @@ -9533,6 +9790,17 @@ func (a *App) getWishlistItemsWithConditions(userID int, includeCompleted bool) } else { condition.CurrentPoints = &totalScore } + // Рассчитываем и форматируем срок разблокировки + if condition.ProjectID != nil && condition.RequiredPoints != nil { + weeks := a.calculateProjectUnlockWeeks( + projectID, + requiredPoints, + startDate, + conditionOwnerID, + ) + weeksText := formatWeeksText(weeks) + condition.WeeksText = &weeksText + } } } } @@ -9818,7 +10086,7 @@ func (a *App) getWishlistHandler(w http.ResponseWriter, r *http.Request) { } } - // Сортируем внутри групп по цене (дорогие → дешёвые) + // Сортируем разблокированные по цене от меньшего к большему sort.Slice(unlocked, func(i, j int) bool { priceI := 0.0 priceJ := 0.0 @@ -9828,21 +10096,53 @@ func (a *App) getWishlistHandler(w http.ResponseWriter, r *http.Request) { if unlocked[j].Price != nil { priceJ = *unlocked[j].Price } - return priceI > priceJ + if priceI == priceJ { + return unlocked[i].ID < unlocked[j].ID + } + return priceI < priceJ // Сортировка по цене от меньшего к большему (заменяет calculateUnlockedSortValue) }) - sort.Slice(locked, func(i, j int) bool { - priceI := 0.0 - priceJ := 0.0 - if locked[i].Price != nil { - priceI = *locked[i].Price + // Разделяем заблокированные на группы + 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 locked[j].Price != nil { - priceJ = *locked[j].Price + if hasUncompletedTasks { + lockedWithTasks = append(lockedWithTasks, item) + } else { + lockedWithoutTasks = append(lockedWithoutTasks, item) } - return priceI > priceJ + } + + // Сортируем каждую группу по времени разблокировки + 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...) + response := WishlistResponse{ Unlocked: unlocked, Locked: locked, @@ -10056,6 +10356,62 @@ func (a *App) checkWishlistAccess(itemID int, userID int) (bool, int, sql.NullIn return hasAccess, itemUserID, boardID, nil } +// CalculateWeeksRequest структура запроса для расчета недель +type CalculateWeeksRequest struct { + ProjectID int `json:"project_id"` + RequiredPoints float64 `json:"required_points"` + StartDate string `json:"start_date,omitempty"` + ConditionUserID *int `json:"condition_user_id,omitempty"` // Владелец условия (если условие существует) +} + +// calculateWeeksHandler обрабатывает запрос на расчет недель для разблокировки условия +func (a *App) calculateWeeksHandler(w http.ResponseWriter, r *http.Request) { + if r.Method == "OPTIONS" { + setCORSHeaders(w) + w.WriteHeader(http.StatusOK) + return + } + setCORSHeaders(w) + + userID, ok := getUserIDFromContext(r) + if !ok { + sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) + return + } + + var req CalculateWeeksRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest) + return + } + + // Определяем владельца условия: + // 1. Если передан condition_user_id в запросе - используем его (для существующего условия) + // 2. Иначе используем текущего пользователя (для нового условия) + conditionOwnerID := userID // userID из контекста (текущий пользователь) + if req.ConditionUserID != nil && *req.ConditionUserID > 0 { + conditionOwnerID = *req.ConditionUserID + } + + var startDate sql.NullTime + if req.StartDate != "" { + date, err := time.Parse("2006-01-02", req.StartDate) + if err == nil { + startDate = sql.NullTime{Time: date, Valid: true} + } + } + + // Используем владельца условия, а не текущего пользователя + weeks := a.calculateProjectUnlockWeeks(req.ProjectID, req.RequiredPoints, startDate, conditionOwnerID) + + response := map[string]interface{}{ + "weeks_text": formatWeeksText(weeks), // Отформатированная строка для отображения + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} + // getWishlistItemHandler возвращает одно желание func (a *App) getWishlistItemHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { @@ -10242,6 +10598,17 @@ func (a *App) getWishlistItemHandler(w http.ResponseWriter, r *http.Request) { dateStr := startDate.Time.Format("2006-01-02") condition.StartDate = &dateStr } + // Рассчитываем и форматируем срок разблокировки + if condition.ProjectID != nil && condition.RequiredPoints != nil { + weeks := a.calculateProjectUnlockWeeks( + *condition.ProjectID, + *condition.RequiredPoints, + startDate, + conditionOwnerID, + ) + weeksText := formatWeeksText(weeks) + condition.WeeksText = &weeksText + } } item.UnlockConditions = append(item.UnlockConditions, condition) @@ -10286,6 +10653,9 @@ func (a *App) getWishlistItemHandler(w http.ResponseWriter, r *http.Request) { item.Unlocked = unlocked } + // Сортируем условия в нужном порядке + a.sortUnlockConditions(item.UnlockConditions, userID) + // Загружаем связанную задачу текущего пользователя, если есть var linkedTaskID, linkedTaskCompleted, linkedTaskUserID sql.NullInt64 var linkedTaskName sql.NullString @@ -10587,6 +10957,17 @@ func (a *App) updateWishlistHandler(w http.ResponseWriter, r *http.Request) { dateStr := startDate.Time.Format("2006-01-02") condition.StartDate = &dateStr } + // Рассчитываем и форматируем срок разблокировки + if condition.ProjectID != nil && condition.RequiredPoints != nil { + weeks := a.calculateProjectUnlockWeeks( + *condition.ProjectID, + *condition.RequiredPoints, + startDate, + conditionOwnerID, + ) + weeksText := formatWeeksText(weeks) + condition.WeeksText = &weeksText + } } item.UnlockConditions = append(item.UnlockConditions, condition) @@ -10630,6 +11011,9 @@ func (a *App) updateWishlistHandler(w http.ResponseWriter, r *http.Request) { updatedItem.Unlocked = unlocked } + // Сортируем условия в нужном порядке + a.sortUnlockConditions(updatedItem.UnlockConditions, userID) + w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(updatedItem) } @@ -12045,26 +12429,63 @@ func (a *App) getBoardItemsHandler(w http.ResponseWriter, r *http.Request) { } } - // Сортируем unlocked по возрастанию суммы баллов (от меньшего к большему) + // Сортируем разблокированные по цене от меньшего к большему sort.Slice(unlocked, func(i, j int) bool { - valueI := calculateUnlockedSortValue(unlocked[i]) - valueJ := calculateUnlockedSortValue(unlocked[j]) - if valueI == valueJ { + 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 + }) + + // Разделяем заблокированные на группы (с задачами и без задач) + 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 }) - // Сортируем locked по возрастанию суммы оставшихся баллов (от меньшего к большему) - sort.Slice(locked, func(i, j int) bool { - valueI := calculateLockedSortValue(locked[i]) - valueJ := calculateLockedSortValue(locked[j]) + sort.Slice(lockedWithTasks, func(i, j int) bool { + valueI := a.calculateLockedSortValue(lockedWithTasks[i], userID) + valueJ := a.calculateLockedSortValue(lockedWithTasks[j], userID) if valueI == valueJ { - return locked[i].ID < locked[j].ID + return lockedWithTasks[i].ID < lockedWithTasks[j].ID } return valueI < valueJ }) + // Объединяем: сначала без задач, потом с задачами + locked = append(lockedWithoutTasks, lockedWithTasks...) + // Считаем завершённые var completedCount int a.DB.QueryRow(`SELECT COUNT(*) FROM wishlist_items WHERE board_id = $1 AND completed = TRUE AND deleted = FALSE`, @@ -12313,27 +12734,77 @@ func calculateUnlockedSortValue(item WishlistItem) float64 { // calculateLockedSortValue считает сумму оставшихся баллов для разблокировки // Задача считается как 1 балл (если не выполнена), project_points как remaining баллы -func calculateLockedSortValue(item WishlistItem) float64 { - var totalRemaining float64 = 0.0 +func (a *App) calculateLockedSortValue(item WishlistItem, userID int) float64 { + // Если нет условий, возвращаем большое значение (отсутствие условий = все выполнены) + if len(item.UnlockConditions) == 0 { + return 999999.0 + } + + maxWeeks := 0.0 + hasProjectConditions := false + for _, condition := range item.UnlockConditions { - if condition.Type == "task_completion" { - if condition.TaskCompleted == nil || !*condition.TaskCompleted { - totalRemaining += 1.0 - } - } else if condition.Type == "project_points" { - if condition.CurrentPoints != nil && condition.RequiredPoints != nil { - remaining := *condition.RequiredPoints - *condition.CurrentPoints - if remaining > 0 { - totalRemaining += remaining + if condition.Type == "project_points" { + hasProjectConditions = true + if condition.RequiredPoints != nil { + var startDate sql.NullTime + if condition.StartDate != nil { + date, err := time.Parse("2006-01-02", *condition.StartDate) + if err == nil { + startDate = sql.NullTime{Time: date, Valid: true} + } + } + + // ВАЖНО: Используем владельца условия из condition.UserID + // Если condition.UserID есть - это владелец условия + // Если нет - получаем владельца желания из БД (для старых условий) + // НЕ используем текущего пользователя (userID), так как условие может принадлежать другому пользователю + conditionOwnerID := 0 + if condition.UserID != nil { + conditionOwnerID = *condition.UserID + } else { + // Если нет владельца условия, получаем владельца желания из БД + var itemOwnerID int + err := a.DB.QueryRow(`SELECT user_id FROM wishlist_items WHERE id = $1`, item.ID).Scan(&itemOwnerID) + if err != nil { + log.Printf("Error getting wishlist item owner for item %d: %v", item.ID, err) + continue // Пропускаем условие, если не можем получить владельца + } + conditionOwnerID = itemOwnerID + } + + // Получаем projectID из условия + if condition.ProjectID != nil { + weeks := a.calculateProjectUnlockWeeks( + *condition.ProjectID, + *condition.RequiredPoints, + startDate, + conditionOwnerID, // Владелец условия, а не текущий пользователь + ) + // weeks > 0 && < 99999 означает, что условие еще не выполнено и расчет успешен + // weeks == 0 означает условие выполнено + // weeks == 99999 означает медиана отсутствует (нельзя рассчитать) или ошибка расчета + if weeks > 0 && weeks < 99999 { + if weeks > maxWeeks { + maxWeeks = weeks + } + } } } } } - // Если нет условий или все условия выполнены, ставим в конец - if totalRemaining == 0.0 && len(item.UnlockConditions) > 0 { + + // Если были условия по проектам, но все выполнены (maxWeeks = 0) + if hasProjectConditions && maxWeeks == 0.0 { return 999999.0 } - return totalRemaining + + // Если не было условий по проектам (только задачи или нет условий) + if !hasProjectConditions { + return 999999.0 + } + + return maxWeeks } // getWishlistItemsByBoard загружает желания конкретной доски @@ -12484,6 +12955,17 @@ func (a *App) getWishlistItemsByBoard(boardID int, userID int) ([]WishlistItem, dateStr := startDate.Time.Format("2006-01-02") condition.StartDate = &dateStr } + // Рассчитываем и форматируем срок разблокировки + if condition.ProjectID != nil && condition.RequiredPoints != nil { + weeks := a.calculateProjectUnlockWeeks( + *condition.ProjectID, + *condition.RequiredPoints, + startDate, + conditionOwnerID, + ) + weeksText := formatWeeksText(weeks) + condition.WeeksText = &weeksText + } } item.UnlockConditions = append(item.UnlockConditions, condition) @@ -12493,6 +12975,9 @@ func (a *App) getWishlistItemsByBoard(boardID int, userID int) ([]WishlistItem, // Преобразуем map в slice и определяем unlocked items := make([]WishlistItem, 0, len(itemsMap)) for _, item := range itemsMap { + // Сортируем условия в нужном порядке + a.sortUnlockConditions(item.UnlockConditions, userID) + // Проверяем все условия item.Unlocked = true if len(item.UnlockConditions) > 0 { @@ -12511,6 +12996,26 @@ func (a *App) getWishlistItemsByBoard(boardID int, userID int) ([]WishlistItem, } } + // Определяем первое заблокированное условие и количество остальных + if !item.Unlocked && !item.Completed { + lockedCount := 0 + var firstLocked *UnlockConditionDisplay + for i := range item.UnlockConditions { + condition := &item.UnlockConditions[i] + if isConditionLocked(*condition) { + lockedCount++ + if firstLocked == nil { + firstLocked = condition + } + } + } + if firstLocked != nil { + item.FirstLockedCondition = firstLocked + item.MoreLockedConditions = lockedCount - 1 + item.LockedConditionsCount = lockedCount + } + } + // Загружаем связанную задачу текущего пользователя, если есть var linkedTaskID, linkedTaskCompleted, linkedTaskUserID sql.NullInt64 var linkedTaskName sql.NullString diff --git a/play-life-backend/migrations/000007_add_projects_median_mv.down.sql b/play-life-backend/migrations/000007_add_projects_median_mv.down.sql new file mode 100644 index 0000000..db3cb17 --- /dev/null +++ b/play-life-backend/migrations/000007_add_projects_median_mv.down.sql @@ -0,0 +1,4 @@ +-- Migration: Drop projects_median_mv materialized view +-- Date: 2026-01-30 + +DROP MATERIALIZED VIEW IF EXISTS projects_median_mv; diff --git a/play-life-backend/migrations/000007_add_projects_median_mv.up.sql b/play-life-backend/migrations/000007_add_projects_median_mv.up.sql new file mode 100644 index 0000000..a464531 --- /dev/null +++ b/play-life-backend/migrations/000007_add_projects_median_mv.up.sql @@ -0,0 +1,34 @@ +-- Migration: Add projects_median_mv materialized view +-- Date: 2026-01-30 +-- +-- This migration creates a materialized view that calculates the median score +-- for each project based on the last 12 weeks of historical data from weekly_report_mv. +-- The view includes user_id to support multi-tenant queries. + +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); + +COMMENT ON MATERIALIZED VIEW projects_median_mv IS 'Materialized view calculating median score for each project based on last 12 weeks of historical data. Includes user_id for multi-tenant support.'; diff --git a/play-life-web/package.json b/play-life-web/package.json index 833083f..6f9d7a6 100644 --- a/play-life-web/package.json +++ b/play-life-web/package.json @@ -1,6 +1,6 @@ { "name": "play-life-web", - "version": "4.5.0", + "version": "4.6.0", "type": "module", "scripts": { "dev": "vite", diff --git a/play-life-web/src/components/WishlistDetail.css b/play-life-web/src/components/WishlistDetail.css index 3e26fd3..c0cb629 100644 --- a/play-life-web/src/components/WishlistDetail.css +++ b/play-life-web/src/components/WishlistDetail.css @@ -112,7 +112,7 @@ display: flex; align-items: center; gap: 0.5rem; - margin-bottom: 0.25rem; + margin-bottom: 0.125rem; } .condition-icon { @@ -124,7 +124,7 @@ } .condition-progress { - margin-top: 0.25rem; + margin-top: 0.125rem; margin-left: calc(16px + 0.5rem); } @@ -134,7 +134,7 @@ background-color: #e5e7eb; border-radius: 4px; overflow: hidden; - margin-bottom: 0.25rem; + margin-bottom: 0.125rem; } .progress-fill { diff --git a/play-life-web/src/components/WishlistDetail.jsx b/play-life-web/src/components/WishlistDetail.jsx index aab9884..42ec3d3 100644 --- a/play-life-web/src/components/WishlistDetail.jsx +++ b/play-life-web/src/components/WishlistDetail.jsx @@ -286,7 +286,10 @@ function WishlistDetail({ wishlistId, onNavigate, onRefresh, boardId }) {
{Math.round(progress.current)} / {Math.round(progress.required)} {progress.remaining > 0 && ( - Осталось: {Math.round(progress.remaining)} + + Осталось: {Math.round(progress.remaining)} + {condition.weeks_text && ` (${condition.weeks_text})`} + )}
diff --git a/play-life-web/src/components/WishlistForm.css b/play-life-web/src/components/WishlistForm.css index 7dab3ce..d889148 100644 --- a/play-life-web/src/components/WishlistForm.css +++ b/play-life-web/src/components/WishlistForm.css @@ -277,20 +277,58 @@ .condition-form { background: white; border-radius: 0.5rem; - padding: 1.5rem; + padding: 0; max-width: 500px; width: 90%; max-height: 90vh; overflow-y: auto; + display: flex; + flex-direction: column; +} + +.condition-form-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1rem 1.5rem; + border-bottom: 1px solid #e5e7eb; } .condition-form h3 { - margin: 0 0 1.5rem 0; + margin: 0; font-size: 1.25rem; font-weight: 600; color: #1f2937; } +.condition-form-close-button { + background: none; + border: none; + font-size: 1.5rem; + color: #6b7280; + cursor: pointer; + padding: 0; + width: 2rem; + height: 2rem; + display: flex; + align-items: center; + justify-content: center; + border-radius: 0.375rem; + transition: background-color 0.2s, color 0.2s; +} + +.condition-form-close-button:hover { + background: #f3f4f6; + color: #1f2937; +} + +.condition-form form { + padding: 1.5rem; + flex: 1; + display: flex; + flex-direction: column; +} + .form-actions { display: flex; gap: 1rem; @@ -310,6 +348,11 @@ transition: all 0.2s; } +.condition-form-submit-button { + width: 100%; + flex: none; +} + .submit-button:hover:not(:disabled) { background: #2980b9; transform: translateY(-1px); diff --git a/play-life-web/src/components/WishlistForm.jsx b/play-life-web/src/components/WishlistForm.jsx index bd436ae..898e9f2 100644 --- a/play-life-web/src/components/WishlistForm.jsx +++ b/play-life-web/src/components/WishlistForm.jsx @@ -98,6 +98,7 @@ function WishlistForm({ onNavigate, wishlistId, editConditionIndex, newTaskId, b start_date: cond.start_date || null, display_order: idx, user_id: cond.user_id || null, + weeks_text: cond.weeks_text || null, }))) } else { setUnlockConditions([]) @@ -253,6 +254,7 @@ function WishlistForm({ onNavigate, wishlistId, editConditionIndex, newTaskId, b start_date: cond.start_date || null, display_order: idx, user_id: cond.user_id || null, + weeks_text: cond.weeks_text || null, }))) } else { setUnlockConditions([]) @@ -798,16 +800,24 @@ function WishlistForm({ onNavigate, wishlistId, editConditionIndex, newTaskId, b const isOwnCondition = !cond.user_id || cond.user_id === user?.id return (
- isOwnCondition && handleEditCondition(idx)} - style={{ cursor: isOwnCondition ? 'pointer' : 'default' }} - title={!isOwnCondition ? 'Чужая цель - нельзя редактировать' : ''} - > - {cond.type === 'task_completion' - ? `Задача: ${tasks.find(t => t.id === cond.task_id)?.name || 'Не выбрана'}` - : `Баллы: ${cond.required_points} в ${projects.find(p => p.project_id === cond.project_id)?.project_name || cond.project_name || 'Не выбран'}${cond.start_date ? ` с ${new Date(cond.start_date + 'T00:00:00').toLocaleDateString('ru-RU')}` : ' за всё время'}`} - +
+ isOwnCondition && handleEditCondition(idx)} + style={{ cursor: isOwnCondition ? 'pointer' : 'default', paddingBottom: '0.125rem' }} + title={!isOwnCondition ? 'Чужая цель - нельзя редактировать' : ''} + > + {cond.type === 'task_completion' + ? tasks.find(t => t.id === cond.task_id)?.name || 'Не выбрана' + : `${cond.required_points} в ${projects.find(p => p.project_id === cond.project_id)?.project_name || cond.project_name || 'Не выбран'}${cond.start_date ? ` с ${new Date(cond.start_date + 'T00:00:00').toLocaleDateString('ru-RU')}` : ' за всё время'}`} + + {cond.type === 'project_points' && cond.weeks_text && ( +
+ Срок: + {cond.weeks_text} +
+ )} +
{isOwnCondition && ( +
@@ -1241,13 +1292,16 @@ function ConditionForm({ tasks, projects, onSubmit, onCancel, editingCondition, )}
- -
+ {type === 'project_points' && calculatedWeeksText && ( +
+ Срок: + {calculatedWeeksText} +
+ )}