4.6.0: Расчет срока разблокировки желаний
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m39s
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m39s
This commit is contained in:
@@ -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 приложение (с пересборкой)
|
||||||
|
- База данных
|
||||||
@@ -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 && (
|
||||||
|
<span className="progress-remaining">
|
||||||
|
Осталось: {Math.round(progress.remaining)} ({condition.weeks_text})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Файл:** `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 приложение (с пересборкой)
|
||||||
|
- База данных
|
||||||
@@ -360,6 +360,7 @@ type WishlistItem struct {
|
|||||||
Completed bool `json:"completed"`
|
Completed bool `json:"completed"`
|
||||||
FirstLockedCondition *UnlockConditionDisplay `json:"first_locked_condition,omitempty"`
|
FirstLockedCondition *UnlockConditionDisplay `json:"first_locked_condition,omitempty"`
|
||||||
MoreLockedConditions int `json:"more_locked_conditions,omitempty"`
|
MoreLockedConditions int `json:"more_locked_conditions,omitempty"`
|
||||||
|
LockedConditionsCount int `json:"locked_conditions_count,omitempty"` // Общее количество заблокированных условий
|
||||||
UnlockConditions []UnlockConditionDisplay `json:"unlock_conditions,omitempty"`
|
UnlockConditions []UnlockConditionDisplay `json:"unlock_conditions,omitempty"`
|
||||||
LinkedTask *LinkedTask `json:"linked_task,omitempty"`
|
LinkedTask *LinkedTask `json:"linked_task,omitempty"`
|
||||||
TasksCount int `json:"tasks_count,omitempty"` // Количество задач для этого желания
|
TasksCount int `json:"tasks_count,omitempty"` // Количество задач для этого желания
|
||||||
@@ -381,6 +382,8 @@ type UnlockConditionDisplay struct {
|
|||||||
// Персональные цели
|
// Персональные цели
|
||||||
UserID *int `json:"user_id,omitempty"` // ID пользователя для персональных целей
|
UserID *int `json:"user_id,omitempty"` // ID пользователя для персональных целей
|
||||||
UserName *string `json:"user_name,omitempty"` // Имя пользователя для персональных целей
|
UserName *string `json:"user_name,omitempty"` // Имя пользователя для персональных целей
|
||||||
|
// Срок разблокировки
|
||||||
|
WeeksText *string `json:"weeks_text,omitempty"` // Отформатированный текст срока разблокировки
|
||||||
}
|
}
|
||||||
|
|
||||||
type WishlistRequest struct {
|
type WishlistRequest struct {
|
||||||
@@ -2812,7 +2815,7 @@ func (a *App) startWeeklyGoalsScheduler() {
|
|||||||
// Cron выражение: "0 6 * * 1" означает: минута=0, час=6, любой день месяца, любой месяц, понедельник (1)
|
// Cron выражение: "0 6 * * 1" означает: минута=0, час=6, любой день месяца, любой месяц, понедельник (1)
|
||||||
_, err = c.AddFunc("0 6 * * 1", func() {
|
_, err = c.AddFunc("0 6 * * 1", func() {
|
||||||
now := time.Now().In(loc)
|
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 (чтобы в ней были данные прошлой недели)
|
// Сначала обновляем MV (чтобы в ней были данные прошлой недели)
|
||||||
_, err := a.DB.Exec("REFRESH MATERIALIZED VIEW weekly_report_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")
|
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 {
|
if err := a.setupWeeklyGoals(); err != nil {
|
||||||
log.Printf("Error in scheduled weekly goals setup: %v", err)
|
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/completed", app.getWishlistCompletedHandler).Methods("GET", "OPTIONS")
|
||||||
protected.HandleFunc("/api/wishlist/metadata", app.extractLinkMetadataHandler).Methods("POST", "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/proxy-image", app.proxyImageHandler).Methods("GET", "OPTIONS")
|
||||||
|
protected.HandleFunc("/api/wishlist/calculate-weeks", app.calculateWeeksHandler).Methods("POST", "OPTIONS")
|
||||||
|
|
||||||
// Wishlist Boards (ВАЖНО: должны быть ПЕРЕД /api/wishlist/{id} чтобы избежать конфликта роутов!)
|
// Wishlist Boards (ВАЖНО: должны быть ПЕРЕД /api/wishlist/{id} чтобы избежать конфликта роутов!)
|
||||||
protected.HandleFunc("/api/wishlist/boards", app.getBoardsHandler).Methods("GET", "OPTIONS")
|
protected.HandleFunc("/api/wishlist/boards", app.getBoardsHandler).Methods("GET", "OPTIONS")
|
||||||
@@ -9165,6 +9177,104 @@ func (a *App) calculateProjectPointsFromDate(
|
|||||||
return totalScore, nil
|
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 проверяет ВСЕ условия для желания
|
// checkWishlistUnlock проверяет ВСЕ условия для желания
|
||||||
// Все условия должны выполняться (AND логика)
|
// Все условия должны выполняться (AND логика)
|
||||||
func (a *App) checkWishlistUnlock(itemID int, userID int) (bool, error) {
|
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
|
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 загружает желания с их условиями
|
// getWishlistItemsWithConditions загружает желания с их условиями
|
||||||
func (a *App) getWishlistItemsWithConditions(userID int, includeCompleted bool) ([]WishlistItem, error) {
|
func (a *App) getWishlistItemsWithConditions(userID int, includeCompleted bool) ([]WishlistItem, error) {
|
||||||
query := `
|
query := `
|
||||||
@@ -9424,6 +9677,9 @@ func (a *App) getWishlistItemsWithConditions(userID int, includeCompleted bool)
|
|||||||
}
|
}
|
||||||
item.Unlocked = unlocked
|
item.Unlocked = unlocked
|
||||||
|
|
||||||
|
// Сортируем условия в нужном порядке
|
||||||
|
a.sortUnlockConditions(item.UnlockConditions, userID)
|
||||||
|
|
||||||
// Определяем первое заблокированное условие и количество остальных, а также рассчитываем прогресс
|
// Определяем первое заблокированное условие и количество остальных, а также рассчитываем прогресс
|
||||||
if !unlocked && !item.Completed {
|
if !unlocked && !item.Completed {
|
||||||
lockedCount := 0
|
lockedCount := 0
|
||||||
@@ -9489,6 +9745,7 @@ func (a *App) getWishlistItemsWithConditions(userID int, includeCompleted bool)
|
|||||||
if firstLocked != nil {
|
if firstLocked != nil {
|
||||||
item.FirstLockedCondition = firstLocked
|
item.FirstLockedCondition = firstLocked
|
||||||
item.MoreLockedConditions = lockedCount - 1
|
item.MoreLockedConditions = lockedCount - 1
|
||||||
|
item.LockedConditionsCount = lockedCount
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Даже если желание разблокировано, рассчитываем прогресс для всех условий
|
// Даже если желание разблокировано, рассчитываем прогресс для всех условий
|
||||||
@@ -9533,6 +9790,17 @@ func (a *App) getWishlistItemsWithConditions(userID int, includeCompleted bool)
|
|||||||
} else {
|
} else {
|
||||||
condition.CurrentPoints = &totalScore
|
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 {
|
sort.Slice(unlocked, func(i, j int) bool {
|
||||||
priceI := 0.0
|
priceI := 0.0
|
||||||
priceJ := 0.0
|
priceJ := 0.0
|
||||||
@@ -9828,21 +10096,53 @@ func (a *App) getWishlistHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
if unlocked[j].Price != nil {
|
if unlocked[j].Price != nil {
|
||||||
priceJ = *unlocked[j].Price
|
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
|
lockedWithoutTasks := []WishlistItem{}
|
||||||
priceJ := 0.0
|
lockedWithTasks := []WishlistItem{}
|
||||||
if locked[i].Price != nil {
|
|
||||||
priceI = *locked[i].Price
|
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
|
|
||||||
}
|
}
|
||||||
return priceI > priceJ
|
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...)
|
||||||
|
|
||||||
response := WishlistResponse{
|
response := WishlistResponse{
|
||||||
Unlocked: unlocked,
|
Unlocked: unlocked,
|
||||||
Locked: locked,
|
Locked: locked,
|
||||||
@@ -10056,6 +10356,62 @@ func (a *App) checkWishlistAccess(itemID int, userID int) (bool, int, sql.NullIn
|
|||||||
return hasAccess, itemUserID, boardID, nil
|
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 возвращает одно желание
|
// getWishlistItemHandler возвращает одно желание
|
||||||
func (a *App) getWishlistItemHandler(w http.ResponseWriter, r *http.Request) {
|
func (a *App) getWishlistItemHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method == "OPTIONS" {
|
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")
|
dateStr := startDate.Time.Format("2006-01-02")
|
||||||
condition.StartDate = &dateStr
|
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)
|
item.UnlockConditions = append(item.UnlockConditions, condition)
|
||||||
@@ -10286,6 +10653,9 @@ func (a *App) getWishlistItemHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
item.Unlocked = unlocked
|
item.Unlocked = unlocked
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Сортируем условия в нужном порядке
|
||||||
|
a.sortUnlockConditions(item.UnlockConditions, userID)
|
||||||
|
|
||||||
// Загружаем связанную задачу текущего пользователя, если есть
|
// Загружаем связанную задачу текущего пользователя, если есть
|
||||||
var linkedTaskID, linkedTaskCompleted, linkedTaskUserID sql.NullInt64
|
var linkedTaskID, linkedTaskCompleted, linkedTaskUserID sql.NullInt64
|
||||||
var linkedTaskName sql.NullString
|
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")
|
dateStr := startDate.Time.Format("2006-01-02")
|
||||||
condition.StartDate = &dateStr
|
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)
|
item.UnlockConditions = append(item.UnlockConditions, condition)
|
||||||
@@ -10630,6 +11011,9 @@ func (a *App) updateWishlistHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
updatedItem.Unlocked = unlocked
|
updatedItem.Unlocked = unlocked
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Сортируем условия в нужном порядке
|
||||||
|
a.sortUnlockConditions(updatedItem.UnlockConditions, userID)
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(updatedItem)
|
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 {
|
sort.Slice(unlocked, func(i, j int) bool {
|
||||||
valueI := calculateUnlockedSortValue(unlocked[i])
|
priceI := 0.0
|
||||||
valueJ := calculateUnlockedSortValue(unlocked[j])
|
priceJ := 0.0
|
||||||
if valueI == valueJ {
|
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 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
|
return valueI < valueJ
|
||||||
})
|
})
|
||||||
|
|
||||||
// Сортируем locked по возрастанию суммы оставшихся баллов (от меньшего к большему)
|
sort.Slice(lockedWithTasks, func(i, j int) bool {
|
||||||
sort.Slice(locked, func(i, j int) bool {
|
valueI := a.calculateLockedSortValue(lockedWithTasks[i], userID)
|
||||||
valueI := calculateLockedSortValue(locked[i])
|
valueJ := a.calculateLockedSortValue(lockedWithTasks[j], userID)
|
||||||
valueJ := calculateLockedSortValue(locked[j])
|
|
||||||
if valueI == valueJ {
|
if valueI == valueJ {
|
||||||
return locked[i].ID < locked[j].ID
|
return lockedWithTasks[i].ID < lockedWithTasks[j].ID
|
||||||
}
|
}
|
||||||
return valueI < valueJ
|
return valueI < valueJ
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Объединяем: сначала без задач, потом с задачами
|
||||||
|
locked = append(lockedWithoutTasks, lockedWithTasks...)
|
||||||
|
|
||||||
// Считаем завершённые
|
// Считаем завершённые
|
||||||
var completedCount int
|
var completedCount int
|
||||||
a.DB.QueryRow(`SELECT COUNT(*) FROM wishlist_items WHERE board_id = $1 AND completed = TRUE AND deleted = FALSE`,
|
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 считает сумму оставшихся баллов для разблокировки
|
// calculateLockedSortValue считает сумму оставшихся баллов для разблокировки
|
||||||
// Задача считается как 1 балл (если не выполнена), project_points как remaining баллы
|
// Задача считается как 1 балл (если не выполнена), project_points как remaining баллы
|
||||||
func calculateLockedSortValue(item WishlistItem) float64 {
|
func (a *App) calculateLockedSortValue(item WishlistItem, userID int) float64 {
|
||||||
var totalRemaining float64 = 0.0
|
// Если нет условий, возвращаем большое значение (отсутствие условий = все выполнены)
|
||||||
for _, condition := range item.UnlockConditions {
|
if len(item.UnlockConditions) == 0 {
|
||||||
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 totalRemaining == 0.0 && len(item.UnlockConditions) > 0 {
|
|
||||||
return 999999.0
|
return 999999.0
|
||||||
}
|
}
|
||||||
return totalRemaining
|
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
// getWishlistItemsByBoard загружает желания конкретной доски
|
// getWishlistItemsByBoard загружает желания конкретной доски
|
||||||
@@ -12484,6 +12955,17 @@ func (a *App) getWishlistItemsByBoard(boardID int, userID int) ([]WishlistItem,
|
|||||||
dateStr := startDate.Time.Format("2006-01-02")
|
dateStr := startDate.Time.Format("2006-01-02")
|
||||||
condition.StartDate = &dateStr
|
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)
|
item.UnlockConditions = append(item.UnlockConditions, condition)
|
||||||
@@ -12493,6 +12975,9 @@ func (a *App) getWishlistItemsByBoard(boardID int, userID int) ([]WishlistItem,
|
|||||||
// Преобразуем map в slice и определяем unlocked
|
// Преобразуем map в slice и определяем unlocked
|
||||||
items := make([]WishlistItem, 0, len(itemsMap))
|
items := make([]WishlistItem, 0, len(itemsMap))
|
||||||
for _, item := range itemsMap {
|
for _, item := range itemsMap {
|
||||||
|
// Сортируем условия в нужном порядке
|
||||||
|
a.sortUnlockConditions(item.UnlockConditions, userID)
|
||||||
|
|
||||||
// Проверяем все условия
|
// Проверяем все условия
|
||||||
item.Unlocked = true
|
item.Unlocked = true
|
||||||
if len(item.UnlockConditions) > 0 {
|
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 linkedTaskID, linkedTaskCompleted, linkedTaskUserID sql.NullInt64
|
||||||
var linkedTaskName sql.NullString
|
var linkedTaskName sql.NullString
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- Migration: Drop projects_median_mv materialized view
|
||||||
|
-- Date: 2026-01-30
|
||||||
|
|
||||||
|
DROP MATERIALIZED VIEW IF EXISTS projects_median_mv;
|
||||||
@@ -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.';
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "play-life-web",
|
"name": "play-life-web",
|
||||||
"version": "4.5.0",
|
"version": "4.6.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -112,7 +112,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
margin-bottom: 0.25rem;
|
margin-bottom: 0.125rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.condition-icon {
|
.condition-icon {
|
||||||
@@ -124,7 +124,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.condition-progress {
|
.condition-progress {
|
||||||
margin-top: 0.25rem;
|
margin-top: 0.125rem;
|
||||||
margin-left: calc(16px + 0.5rem);
|
margin-left: calc(16px + 0.5rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,7 +134,7 @@
|
|||||||
background-color: #e5e7eb;
|
background-color: #e5e7eb;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
margin-bottom: 0.25rem;
|
margin-bottom: 0.125rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-fill {
|
.progress-fill {
|
||||||
|
|||||||
@@ -286,7 +286,10 @@ function WishlistDetail({ wishlistId, onNavigate, onRefresh, boardId }) {
|
|||||||
<div className="progress-text">
|
<div className="progress-text">
|
||||||
<span>{Math.round(progress.current)} / {Math.round(progress.required)}</span>
|
<span>{Math.round(progress.current)} / {Math.round(progress.required)}</span>
|
||||||
{progress.remaining > 0 && (
|
{progress.remaining > 0 && (
|
||||||
<span className="progress-remaining">Осталось: {Math.round(progress.remaining)}</span>
|
<span className="progress-remaining">
|
||||||
|
Осталось: {Math.round(progress.remaining)}
|
||||||
|
{condition.weeks_text && ` (${condition.weeks_text})`}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -277,20 +277,58 @@
|
|||||||
.condition-form {
|
.condition-form {
|
||||||
background: white;
|
background: white;
|
||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem;
|
||||||
padding: 1.5rem;
|
padding: 0;
|
||||||
max-width: 500px;
|
max-width: 500px;
|
||||||
width: 90%;
|
width: 90%;
|
||||||
max-height: 90vh;
|
max-height: 90vh;
|
||||||
overflow-y: auto;
|
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 {
|
.condition-form h3 {
|
||||||
margin: 0 0 1.5rem 0;
|
margin: 0;
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #1f2937;
|
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 {
|
.form-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
@@ -310,6 +348,11 @@
|
|||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.condition-form-submit-button {
|
||||||
|
width: 100%;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
.submit-button:hover:not(:disabled) {
|
.submit-button:hover:not(:disabled) {
|
||||||
background: #2980b9;
|
background: #2980b9;
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ function WishlistForm({ onNavigate, wishlistId, editConditionIndex, newTaskId, b
|
|||||||
start_date: cond.start_date || null,
|
start_date: cond.start_date || null,
|
||||||
display_order: idx,
|
display_order: idx,
|
||||||
user_id: cond.user_id || null,
|
user_id: cond.user_id || null,
|
||||||
|
weeks_text: cond.weeks_text || null,
|
||||||
})))
|
})))
|
||||||
} else {
|
} else {
|
||||||
setUnlockConditions([])
|
setUnlockConditions([])
|
||||||
@@ -253,6 +254,7 @@ function WishlistForm({ onNavigate, wishlistId, editConditionIndex, newTaskId, b
|
|||||||
start_date: cond.start_date || null,
|
start_date: cond.start_date || null,
|
||||||
display_order: idx,
|
display_order: idx,
|
||||||
user_id: cond.user_id || null,
|
user_id: cond.user_id || null,
|
||||||
|
weeks_text: cond.weeks_text || null,
|
||||||
})))
|
})))
|
||||||
} else {
|
} else {
|
||||||
setUnlockConditions([])
|
setUnlockConditions([])
|
||||||
@@ -798,16 +800,24 @@ function WishlistForm({ onNavigate, wishlistId, editConditionIndex, newTaskId, b
|
|||||||
const isOwnCondition = !cond.user_id || cond.user_id === user?.id
|
const isOwnCondition = !cond.user_id || cond.user_id === user?.id
|
||||||
return (
|
return (
|
||||||
<div key={idx} className="condition-item">
|
<div key={idx} className="condition-item">
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||||
<span
|
<span
|
||||||
className={`condition-item-text ${!isOwnCondition ? 'condition-item-other-user' : ''}`}
|
className={`condition-item-text ${!isOwnCondition ? 'condition-item-other-user' : ''}`}
|
||||||
onClick={() => isOwnCondition && handleEditCondition(idx)}
|
onClick={() => isOwnCondition && handleEditCondition(idx)}
|
||||||
style={{ cursor: isOwnCondition ? 'pointer' : 'default' }}
|
style={{ cursor: isOwnCondition ? 'pointer' : 'default', paddingBottom: '0.125rem' }}
|
||||||
title={!isOwnCondition ? 'Чужая цель - нельзя редактировать' : ''}
|
title={!isOwnCondition ? 'Чужая цель - нельзя редактировать' : ''}
|
||||||
>
|
>
|
||||||
{cond.type === 'task_completion'
|
{cond.type === 'task_completion'
|
||||||
? `Задача: ${tasks.find(t => t.id === cond.task_id)?.name || 'Не выбрана'}`
|
? 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.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')}` : ' за всё время'}`}
|
||||||
</span>
|
</span>
|
||||||
|
{cond.type === 'project_points' && cond.weeks_text && (
|
||||||
|
<div style={{ color: '#666', fontSize: '0.85em' }}>
|
||||||
|
<span>Срок: </span>
|
||||||
|
<span style={{ fontWeight: '600' }}>{cond.weeks_text}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{isOwnCondition && (
|
{isOwnCondition && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -849,6 +859,7 @@ function WishlistForm({ onNavigate, wishlistId, editConditionIndex, newTaskId, b
|
|||||||
editingCondition={editingConditionIndex !== null ? unlockConditions[editingConditionIndex] : null}
|
editingCondition={editingConditionIndex !== null ? unlockConditions[editingConditionIndex] : null}
|
||||||
onCreateTask={handleCreateTaskFromCondition}
|
onCreateTask={handleCreateTaskFromCondition}
|
||||||
preselectedTaskId={newTaskId}
|
preselectedTaskId={newTaskId}
|
||||||
|
authFetch={authFetch}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1125,12 +1136,13 @@ function TaskAutocomplete({ tasks, value, onChange, onCreateTask, preselectedTas
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Компонент формы цели
|
// Компонент формы цели
|
||||||
function ConditionForm({ tasks, projects, onSubmit, onCancel, editingCondition, onCreateTask, preselectedTaskId }) {
|
function ConditionForm({ tasks, projects, onSubmit, onCancel, editingCondition, onCreateTask, preselectedTaskId, authFetch }) {
|
||||||
const [type, setType] = useState(editingCondition?.type || 'project_points')
|
const [type, setType] = useState(editingCondition?.type || 'project_points')
|
||||||
const [taskId, setTaskId] = useState(editingCondition?.task_id || null)
|
const [taskId, setTaskId] = useState(editingCondition?.task_id || null)
|
||||||
const [projectId, setProjectId] = useState(editingCondition?.project_id?.toString() || '')
|
const [projectId, setProjectId] = useState(editingCondition?.project_id?.toString() || '')
|
||||||
const [requiredPoints, setRequiredPoints] = useState(editingCondition?.required_points?.toString() || '')
|
const [requiredPoints, setRequiredPoints] = useState(editingCondition?.required_points?.toString() || '')
|
||||||
const [startDate, setStartDate] = useState(editingCondition?.start_date || '')
|
const [startDate, setStartDate] = useState(editingCondition?.start_date || '')
|
||||||
|
const [calculatedWeeksText, setCalculatedWeeksText] = useState(null)
|
||||||
|
|
||||||
const isEditing = editingCondition !== null
|
const isEditing = editingCondition !== null
|
||||||
|
|
||||||
@@ -1142,6 +1154,40 @@ function ConditionForm({ tasks, projects, onSubmit, onCancel, editingCondition,
|
|||||||
}
|
}
|
||||||
}, [preselectedTaskId, editingCondition])
|
}, [preselectedTaskId, editingCondition])
|
||||||
|
|
||||||
|
// Расчет недель при изменении проекта, баллов или даты
|
||||||
|
useEffect(() => {
|
||||||
|
const calculateWeeks = async () => {
|
||||||
|
if (type === 'project_points' && projectId && requiredPoints && authFetch) {
|
||||||
|
try {
|
||||||
|
const response = await authFetch('/api/wishlist/calculate-weeks', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
project_id: parseInt(projectId),
|
||||||
|
required_points: parseFloat(requiredPoints),
|
||||||
|
start_date: startDate || '',
|
||||||
|
condition_user_id: editingCondition?.user_id || null,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json()
|
||||||
|
setCalculatedWeeksText(data.weeks_text || null)
|
||||||
|
} else {
|
||||||
|
setCalculatedWeeksText(null)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error calculating weeks:', err)
|
||||||
|
setCalculatedWeeksText(null)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setCalculatedWeeksText(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
calculateWeeks()
|
||||||
|
}, [type, projectId, requiredPoints, startDate, editingCondition?.user_id, authFetch])
|
||||||
|
|
||||||
const handleSubmit = (e) => {
|
const handleSubmit = (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation() // Предотвращаем всплытие события
|
e.stopPropagation() // Предотвращаем всплытие события
|
||||||
@@ -1173,7 +1219,12 @@ function ConditionForm({ tasks, projects, onSubmit, onCancel, editingCondition,
|
|||||||
return (
|
return (
|
||||||
<div className="condition-form-overlay" onClick={onCancel}>
|
<div className="condition-form-overlay" onClick={onCancel}>
|
||||||
<div className="condition-form" onClick={(e) => e.stopPropagation()}>
|
<div className="condition-form" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="condition-form-header">
|
||||||
<h3>{isEditing ? 'Редактировать цель' : 'Добавить цель'}</h3>
|
<h3>{isEditing ? 'Редактировать цель' : 'Добавить цель'}</h3>
|
||||||
|
<button onClick={onCancel} className="condition-form-close-button">
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>Тип условия</label>
|
<label>Тип условия</label>
|
||||||
@@ -1241,13 +1292,16 @@ function ConditionForm({ tasks, projects, onSubmit, onCancel, editingCondition,
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="form-actions">
|
<div className="form-actions">
|
||||||
<button type="button" onClick={onCancel} className="cancel-button">
|
<button type="submit" className="submit-button condition-form-submit-button">
|
||||||
Отмена
|
|
||||||
</button>
|
|
||||||
<button type="submit" className="submit-button">
|
|
||||||
{isEditing ? 'Сохранить' : 'Добавить'}
|
{isEditing ? 'Сохранить' : 'Добавить'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{type === 'project_points' && calculatedWeeksText && (
|
||||||
|
<div className="calculated-weeks-info" style={{ marginTop: '4px', textAlign: 'left', color: '#666', fontSize: '0.85em' }}>
|
||||||
|
<span>Срок: </span>
|
||||||
|
<span style={{ fontWeight: '600' }}>{calculatedWeeksText}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user