Compare commits

...

254 Commits

Author SHA1 Message Date
poignatov
60a0efafad 6.27.3: Фикс сброса автовыполнения и прогресс
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m28s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 12:21:45 +03:00
poignatov
48fa192cdc 6.27.2: Fitbit не меняет auto_complete в драфтах
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m19s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 12:13:33 +03:00
poignatov
06cc1a4b3b 6.27.1: Fitbit не меняет auto_complete в драфтах
Some checks failed
Build and Push Docker Image / build-and-push (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 12:12:34 +03:00
poignatov
e2966aedd1 6.27.0: Автовыполнение и прогрессия по-умолчанию
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m32s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 12:04:45 +03:00
poignatov
d561683e12 6.26.1: Автовыполнение задач сортируется наверх
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m12s
2026-03-20 11:24:13 +03:00
poignatov
fdc3e01443 6.26.0: Объединённый диалог товаров
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m17s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 11:21:47 +03:00
poignatov
d4f0064aa7 6.25.5: Стиль остатка как у подзадач
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m10s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 20:38:23 +03:00
poignatov
87126a480a 6.25.4: Стиль остатка как у подзадач
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 38s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 20:36:22 +03:00
poignatov
837ddbe4ed 6.25.4: Карандаш вместо троеточия на карточках
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m8s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 20:28:56 +03:00
poignatov
44bbb46a1a 6.25.3: Остаток в скобках вместо тильды
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m6s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 20:23:40 +03:00
poignatov
1795a66ee1 6.25.2: Выравнивание текста в селекторе доски
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m7s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 20:22:06 +03:00
poignatov
84b5aa9390 6.25.1: Иконка архива в селекторе доски
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m9s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 20:20:37 +03:00
poignatov
e8a766205f 6.25.0: Меню действий для досок вместо кнопок
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m11s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 20:10:34 +03:00
poignatov
6e7ebb9aa3 6.24.0: Кнопка архива рядом с создать доску
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m11s
2026-03-19 19:22:10 +03:00
poignatov
101f4e27ed 6.23.0: Архивация досок желаний и товаров
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m26s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 18:27:19 +03:00
poignatov
f1c12fd81a 6.22.0: Авторасчёт сроков товаров по истории
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m23s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 11:37:39 +03:00
poignatov
664adcfaa5 6.21.3: Сортировка задач по алфавиту
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m11s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 09:36:45 +03:00
poignatov
8acfaf19ac 6.21.2: Увеличен градиент нижней панели желаний
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m7s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 21:49:33 +03:00
poignatov
2fde471076 6.21.1: Меню действий на экране желания
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m7s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 21:46:46 +03:00
poignatov
5f05b77d36 6.21.0: Копирование товаров и меню действий
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m20s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 21:43:04 +03:00
poignatov
eb68eca63f 6.20.0: Копирование задач и меню действий
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m23s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 21:39:15 +03:00
poignatov
b82db8d80f 6.19.13: Тест сборки
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m9s
2026-03-18 20:30:52 +03:00
poignatov
b3403ff23a 6.19.12: Тест сборки
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 1m36s
2026-03-18 20:28:05 +03:00
poignatov
b8373eb986 6.19.11: Тест сборки
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 1m18s
2026-03-18 20:23:45 +03:00
poignatov
df17ecf943 6.19.10: Увеличена область клика кнопки переноса
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 1m18s
2026-03-18 18:38:14 +03:00
poignatov
42ea241b7c 6.19.9: Ускорение run.sh с BuildKit
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 1m14s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 17:06:02 +03:00
poignatov
3a06d9148c 6.19.8: Кнопки сохранения досок внизу экрана
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 1m16s
2026-03-18 16:59:18 +03:00
poignatov
b1f4fdd449 6.19.7: Сброс скролла и загрузка при смене проекта
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 1m16s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 16:41:55 +03:00
poignatov
01cd0e9003 6.19.6: Кеш-first и отмена запросов при смене доски
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 1m17s
2026-03-18 16:24:32 +03:00
poignatov
6dc3ec828f 6.19.5: Поднятие патч версии
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 1m15s
2026-03-17 21:25:23 +03:00
poignatov
0a8ff4dfab 6.19.4: Поднятие патч версии
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 1m4s
2026-03-17 21:23:01 +03:00
poignatov
dff929c52c 6.19.3: Навигация на редактирование из диалога задачи
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 1m15s
2026-03-17 10:37:02 +03:00
poignatov
2104fea5e2 6.19.2: Фикс кнопки сохранения в приоритетах
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 1m18s
2026-03-17 10:29:52 +03:00
poignatov
caa8ac6ebb 6.19.1: Фикс сброса скролла в статистике
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 1m16s
2026-03-17 10:19:31 +03:00
poignatov
49f67ec36d 6.19.0: Унификация кнопок сохранения в формах
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 1m18s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 10:08:23 +03:00
poignatov
171befdf05 6.18.17: Откат: скобки и без жирного в подзадачах
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 1m14s
2026-03-16 16:20:34 +03:00
poignatov
0c6ba5c8fb 6.18.16: Отступ между карточкой и кнопкой в задаче
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 1m17s
2026-03-16 16:19:08 +03:00
poignatov
1b7e2cd887 6.18.15: Фикс цвета кнопки и отступа снизу в задаче
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 1m13s
2026-03-16 13:59:29 +03:00
poignatov
53593fdc3d 6.18.14: Убран лишний отступ снизу в карточке задачи
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 1m14s
2026-03-16 13:56:11 +03:00
poignatov
b888d056e4 Убран лишний отступ снизу в карточке задачи
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 45s
2026-03-16 13:55:14 +03:00
poignatov
cbf20ce679 6.18.13: Фикс стилей кнопок в редактировании задачи
Some checks failed
Build and Push Docker Image / build-and-push (push) Has been cancelled
2026-03-16 13:54:09 +03:00
poignatov
91bba98b65 6.18.12: Стили кнопок и отступ в редактировании задачи
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 1m14s
2026-03-16 13:34:06 +03:00
poignatov
c927f55fd6 6.18.11: Фикс сброса скролла на статистике
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 1m15s
2026-03-16 13:29:55 +03:00
poignatov
ae13b2bcac 6.18.10: Горизонтальный скролл для чипсов на статистике
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 1m13s
2026-03-16 13:26:59 +03:00
poignatov
85e9a6f48b 6.18.9: Убраны скобки у счётчика подзадач
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 1m16s
2026-03-16 13:22:40 +03:00
poignatov
7309deb98f 6.18.8: Жирный счётчик выполненных подзадач
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 1m13s
2026-03-16 08:54:41 +03:00
poignatov
10f370b0da 6.18.7: Фикс замены $name в автовыполнении
Some checks failed
Build and Push Docker Image / build-and-push (push) Has been cancelled
2026-03-16 08:53:21 +03:00
poignatov
f59453783a 6.18.6: Отступ снизу для кнопки в тесте и форме задачи
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 1m16s
2026-03-16 08:48:32 +03:00
poignatov
5ea58476cb 6.18.5: Фиксированные кнопки в формах задачи и желания
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 1m19s
2026-03-16 07:49:56 +03:00
poignatov
1876595005 6.18.4: Фикс загрузки товаров при создании доски
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 1m14s
2026-03-15 20:00:12 +03:00
poignatov
a4dcc62a37 6.18.3: Фиксированная кнопка Начать/Завершить в тесте
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 1m15s
2026-03-15 19:55:50 +03:00
poignatov
8749f21ac8 6.18.2: Фикс навигации назад после создания задачи
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 1m16s
2026-03-15 18:48:34 +03:00
poignatov
912ae7a857 6.18.1: Фикс логики минуса в полях прогрессии
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 1m15s
2026-03-15 18:14:41 +03:00
poignatov
7ec76ea59b 6.18.0: Улучшения диалога товара и кнопки прогрессии
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 1m13s
2026-03-15 18:13:20 +03:00
poignatov
4f69481efe 6.17.5: Кнопка выполнения и тайтл в диалоге товара
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 1m13s
2026-03-15 18:09:26 +03:00
poignatov
b85b85a27f 6.17.4: Фикс закрытия экрана закупки через history.back
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 1m14s
2026-03-15 18:04:54 +03:00
poignatov
710adff385 6.17.3: Фикс навигации при редактировании из закупки
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 1m14s
2026-03-15 18:01:28 +03:00
poignatov
9c915d4675 6.17.2: Навигация закупки и next_show_at для товара
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 1m29s
2026-03-15 17:54:23 +03:00
poignatov
7e0f979ae3 6.17.1: Общая стратегия по умолчанию в задаче-желании
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 1m30s
2026-03-15 17:41:56 +03:00
poignatov
d42535f36e 6.17.0: Счётчик подзадач с учётом драфта
Some checks failed
Build and Push Docker Image / build-and-push (push) Has been cancelled
2026-03-15 17:41:46 +03:00
poignatov
95985f97f2 6.16.5: Оптимизация выполнения задачи
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 1m39s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 13:14:25 +03:00
poignatov
7889922d9b 6.16.4: Синяя обводка для разблокированных желаний
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m4s
2026-03-13 22:05:05 +03:00
poignatov
97753f4465 6.16.3: Разблокировано вместо Готово
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m25s
2026-03-13 22:00:46 +03:00
poignatov
b1ffb7ba7d 6.16.2: В конце дня вместо скоро
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m5s
2026-03-13 21:58:21 +03:00
poignatov
c232bb40a3 6.16.1: Обновление статистики после редактирования задачи
Some checks failed
Build and Push Docker Image / build-and-push (push) Has been cancelled
2026-03-13 21:57:11 +03:00
poignatov
3bd864d41a 6.16.0: Редактирование записей в статистике
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m24s
2026-03-13 21:48:11 +03:00
poignatov
193b4138d9 6.15.6: Открытие редактирования задачи из статистики
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m6s
2026-03-13 15:49:09 +03:00
poignatov
06b7c614ed 6.15.5: Драфты в статистике текущей недели
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m29s
2026-03-13 15:41:10 +03:00
poignatov
b51b9421be 6.15.4: Подзадачи в драфтах авто-выполнения
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m28s
2026-03-13 15:28:50 +03:00
poignatov
0dca57964d 6.15.3: Фикс обновления данных при редактировании завершённого желания
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m7s
2026-03-13 15:15:01 +03:00
poignatov
95ed1b48fe 6.15.2: Общая стратегия по умолчанию для задач-желаний
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m2s
2026-03-13 14:54:16 +03:00
poignatov
6f76c4a25c 6.15.1: Фикс race condition при смене доски желаний
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m25s
2026-03-13 14:52:07 +03:00
poignatov
c8a47ff408 6.15.0: Драфты авто-выполнения в статистике
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m22s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 14:43:19 +03:00
poignatov
4ce8ba66cc 6.14.2: Фикс nginx маршрута /priorities/confirm на проде
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m6s
2026-03-12 17:27:18 +03:00
poignatov
c42cdfe35b 6.14.1: Фикс nginx маршрута /priorities/confirm на проде
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 36s
2026-03-12 17:23:34 +03:00
poignatov
4971b2a305 6.14.1: Убран хинт 'Открыть статистику'
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m4s
2026-03-12 17:18:36 +03:00
poignatov
64493b9c1f 6.14.0: Еженедельное подтверждение приоритетов
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m19s
2026-03-12 17:16:57 +03:00
poignatov
1df00bbefd 6.13.1: Фикс обновления текущего дня в статистике
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m5s
2026-03-12 15:53:22 +03:00
poignatov
0b5106458a 6.13.0: Переменные $name и $subtaskName в сообщениях награды
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m19s
2026-03-12 15:28:25 +03:00
poignatov
02c8b7537a 6.12.1: Фикс диалога ошибки загрузки
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m8s
2026-03-12 15:20:27 +03:00
poignatov
2a61b17187 6.12.0: Улучшения блока выбора доски в товарах и желаниях
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m5s
2026-03-11 09:46:41 +03:00
poignatov
3624cfffbd 6.11.5: Стили блока выбора доски в товарах и желаниях
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m5s
2026-03-11 09:41:27 +03:00
poignatov
a35797a1f9 6.11.4: Товары перенесены из таба в профиль
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m7s
2026-03-11 09:34:36 +03:00
poignatov
20778d6d39 6.11.3: Настройки доски внутри селекта
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m6s
2026-03-11 09:18:56 +03:00
poignatov
ac1f6c3a47 6.11.2: Фикс стилей блока особенностей задачи
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m5s
2026-03-11 09:14:07 +03:00
poignatov
25f193a061 6.11.1: Фикс моргания кнопки завершения закупки
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m3s
2026-03-11 09:08:40 +03:00
poignatov
ea1720506a 6.11.0: Фикс навигации и переключение типов задач
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m8s
2026-03-11 09:04:11 +03:00
poignatov
dc50433eb1 6.10.0: Синий блок настроек для задач
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m6s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 23:49:24 +03:00
poignatov
4b28d90d68 6.9.2: Фикс стилей чекбоксов закупки
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 2m5s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 23:31:25 +03:00
poignatov
fb1ccd7831 6.9.1: Фикс сброса типа задачи при создании
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m8s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 23:08:52 +03:00
poignatov
636f53eb04 6.9.0: Задачи-закупки
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m20s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 22:37:03 +03:00
poignatov
786a03bf86 6.8.3: Последний объём из истории в плейсхолдере
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m20s
2026-03-10 17:57:03 +03:00
poignatov
eb5e5a5476 6.8.2: Перестановка галочки и инпута объёма
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m6s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 17:50:55 +03:00
poignatov
ebd1398a81 6.8.1: Фикс истории навигации при закрытии крестиком
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m5s
2026-03-10 17:18:14 +03:00
poignatov
3cac8d0452 6.8.0: История покупок товаров
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m20s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 17:05:59 +03:00
poignatov
e962f49407 6.7.0: Описание товаров и редизайн выполнения
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m24s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 16:39:23 +03:00
poignatov
79fa0538f9 6.6.4: Очистка истории при закрытии диалога желания
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m3s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 16:17:03 +03:00
poignatov
99b0eba701 6.6.3: Фикс закрытия желания при выполнении задачи
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m5s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 16:12:39 +03:00
poignatov
23f16a8bef 6.6.2: Фикс навигации при создании задачи из желания
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m6s
2026-03-10 15:59:05 +03:00
poignatov
a441a3d1e7 6.6.1: Скрытие прошедших дат в товарах
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m5s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 15:48:09 +03:00
poignatov
7957776f53 6.6.0: Извлечение ссылки из текста в желании
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m5s
2026-03-10 15:41:34 +03:00
poignatov
a693d3fa4b 6.5.0: Табы вместо селекта в форме цели
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m6s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 22:23:41 +03:00
poignatov
3a1b836ece 6.4.13: Фикс кнопки назад для диалога цели
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m5s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 22:08:24 +03:00
poignatov
17e6bbf9f1 6.4.12: Навигация назад по стеку после создания
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m4s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 21:42:18 +03:00
poignatov
c9a8b994eb 6.4.11: Фикс кнопки назад для диалога желания
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m6s
2026-03-09 21:36:34 +03:00
poignatov
b47d50f51c 6.4.10: Фикс закрытия диалога желания при создании задачи
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m4s
2026-03-09 21:10:02 +03:00
poignatov
37d5c87a55 6.4.9: Скрытие даты для товаров с прошедшей датой
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m11s
2026-03-09 21:04:21 +03:00
poignatov
c911950cc1 6.4.8: Фикс кнопки назад при создании словаря
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m3s
2026-03-08 19:56:25 +03:00
poignatov
2ec5860d78 6.4.7: Фикс закрытия диалога желания при закрытии задачи
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m5s
2026-03-08 19:51:36 +03:00
poignatov
5d257cd0f8 6.4.6: Поднятие патч версии
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m5s
2026-03-08 19:37:39 +03:00
poignatov
e7ce7b2092 6.4.5: Фикс чипса Сегодня для товаров без даты
Some checks failed
Build and Push Docker Image / build-and-push (push) Has been cancelled
2026-03-08 19:37:06 +03:00
poignatov
81de26e586 6.4.4: Логика чипсов и кнопки Без даты
Some checks failed
Build and Push Docker Image / build-and-push (push) Has been cancelled
2026-03-08 19:36:45 +03:00
poignatov
4169285394 6.4.3: Фоновое обновление досок при наличии кеша
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m4s
2026-03-08 19:30:56 +03:00
poignatov
c8fead4034 6.4.2: Закрытие диалогов кнопкой назад (товары)
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m19s
2026-03-08 19:25:35 +03:00
poignatov
01631ff13b 6.4.1: Сброс формы товара при закрытии
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m19s
2026-03-08 19:20:33 +03:00
poignatov
60fca2d93c 6.4.0: Экран товаров (Shopping List)
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m22s
2026-03-08 16:11:08 +03:00
poignatov
cd51b097c8 6.3.10: Фикс зоны клика кнопок прогрессии
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m2s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 14:17:38 +03:00
poignatov
fd416b4bd5 6.3.9: Поднятие патч версии
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m6s
2026-03-08 14:13:25 +03:00
poignatov
537ad9e06e 6.3.8: Фикс визуала кнопок прогрессии
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 34s
2026-03-08 14:00:27 +03:00
poignatov
b0155e6cbe 6.3.8: Увеличена зона клика кнопок +/- прогрессии
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m4s
2026-03-08 13:57:56 +03:00
poignatov
999fa15267 6.3.7: Поднятие патч версии
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m5s
2026-03-08 13:53:54 +03:00
poignatov
67334dde3c 6.3.6: Фикс подстановки base_progress при авто-выполнении
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 58s
2026-03-08 13:52:06 +03:00
poignatov
7e51b0cb9f 6.3.6: Авто-подстановка base_progress в драфт
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m19s
2026-03-08 12:57:59 +03:00
poignatov
3a6f223aac 6.3.5: Фикс пустого таба и ошибки после авторизации
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m6s
2026-03-08 12:51:44 +03:00
poignatov
e1b6fcb918 6.3.4: Сброс на current при рестарте PWA
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m8s
2026-03-08 12:41:25 +03:00
poignatov
f54a0fff14 6.3.3: Фикс навигации при перезапуске PWA
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m19s
2026-03-07 20:13:16 +03:00
poignatov
80800da839 6.3.2: Буст × 1.2 для группы остальных приоритетов
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m19s
2026-03-07 19:35:36 +03:00
poignatov
c7b684491c 6.3.1: Буст Group2 × 1.2, max 120%
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m32s
2026-03-07 19:29:19 +03:00
poignatov
7f51411175 6.3.0: Готовые желания на экране прогресса недели
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m19s
2026-03-05 19:41:43 +03:00
poignatov
25317997e5 6.2.0: Срок разблокировки в днях в карточках желаний
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m4s
2026-03-05 18:34:40 +03:00
poignatov
7c5b80b314 6.1.0: Редизайн карточек желаний на экране прогресса
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m20s
2026-03-05 18:30:49 +03:00
poignatov
368f10bcdd 6.0.2: Учёт pending-баллов на желаниях, чёрный текст 2026-03-05 18:08:29 +03:00
poignatov
ff16f98736 6.0.1: Срок разблокировки цели при редактировании
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m5s
2026-03-05 12:59:31 +03:00
poignatov
027063dfb9 6.0.0: Прогресс «Остальные» 100% только при 100%
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m17s
2026-03-05 12:49:21 +03:00
poignatov
7fdcbb75da 5.13.0: Карточки проектов и желаний на экране недели
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m10s
2026-03-05 12:42:52 +03:00
poignatov
2b7b056562 5.12.0: Желания в карточках проектов на неделе
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m21s
2026-03-04 19:07:39 +03:00
poignatov
20773a29b7 5.11.2: Случайная сторона карточки при каждом показе слова
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m3s
2026-03-04 18:28:08 +03:00
poignatov
6caed05c9f 5.11.1: Стили TaskList и карточек Wishlist
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m3s
2026-03-04 18:22:58 +03:00
poignatov
92453def91 5.11.0: Кнопки +/- для прогрессии в TaskDetail
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m6s
2026-03-04 18:17:11 +03:00
poignatov
9f3637113d 5.10.2: Исправлен сброс прогрессии в null
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m22s
2026-03-04 18:09:50 +03:00
poignatov
98427f5d0e 5.10.1: Не сбрасывать подзадачи при быстрой прогрессии
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m22s
2026-03-04 15:11:11 +03:00
poignatov
81dc23b501 5.10.0: Капсула прогрессии в списке задач
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m21s
2026-03-04 12:24:34 +03:00
poignatov
91d4a7337c 5.9.0: Статус Отклонено для желаний
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m20s
2026-03-04 12:04:26 +03:00
poignatov
0f1f5e3943 5.8.0: Окно переноса и логика next_show_at
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m22s
2026-03-04 11:39:03 +03:00
poignatov
8ea71ef95f 5.7.0: Драфты учитываются в today_change статистики
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m21s
2026-03-04 10:19:41 +03:00
poignatov
e457113fc9 5.6.0: Учёт баллов из драфтов в статистике недели
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m26s
2026-03-04 10:13:58 +03:00
poignatov
c04422ed69 5.5.0: Срок разблокировки из min_goal_score
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m24s
2026-02-24 17:29:02 +03:00
poignatov
7bbd732d72 5.4.0: Картинка желания по ссылке и единая высота инпутов
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m3s
2026-02-24 17:06:44 +03:00
poignatov
ad52cf93ea 5.3.4: Поиск — группировка справа, крестик слева
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m9s
2026-02-24 16:27:20 +03:00
poignatov
ba6d823354 5.3.3: Скрытие даты в списке для сегодня и прошлого
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m17s
2026-02-24 15:59:51 +03:00
poignatov
e41abb2bff Перезагрузка списка записей при открытии экрана прогресса
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m4s
2026-02-24 15:57:13 +03:00
poignatov
2236f95ffa 5.3.1: Обновление списка задач Fitbit при открытии меню
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m13s
2026-02-24 15:50:41 +03:00
poignatov
07c4deaf70 5.3.0: Кнопка «По плану», логика чипа «Сегодня»
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m4s
2026-02-23 12:29:19 +03:00
poignatov
cea2c341a2 5.2.2: Логирование пути и версии миграций
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m21s
2026-02-23 12:02:18 +03:00
poignatov
bff62c0b8f 5.2.1: Миграция удаления weekly_goals.max_score
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m25s
2026-02-23 11:28:42 +03:00
poignatov
a5e3396017 5.2.0: min_goal по среднему вместо медианы
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m27s
2026-02-22 14:26:18 +03:00
poignatov
41f8df36a9 5.1.3: Сброс данных при выходе с экрана Fitbit
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m4s
2026-02-09 17:43:21 +03:00
poignatov
76049b3da5 5.1.2: Достижение цели Fitbit для задачи без подзадачи
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m20s
2026-02-09 17:40:29 +03:00
poignatov
9cfb988960 5.1.1: Интерфейс Fitbit и синхронизация в 23:50
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m21s
2026-02-09 17:33:05 +03:00
poignatov
242183a422 5.1.0: Fitbit: привязки к задачам, цели из API
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m23s
2026-02-09 17:06:08 +03:00
poignatov
29bd50acab 5.0.9: Ожидание бэкенда при завершении теста
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m5s
2026-02-09 16:07:36 +03:00
poignatov
72da547b80 5.0.8: часовой пояс, разблокировка по удалению задачи
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m21s
2026-02-09 15:47:57 +03:00
poignatov
1fe3819be6 5.0.6: Удаление max_score, норма по max_goal_score
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m20s
2026-02-09 15:28:44 +03:00
poignatov
1f5f3299f8 5.0.5: normalized для текущей недели в статистике
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m22s
2026-02-09 14:52:45 +03:00
poignatov
7012f1c8ed 5.0.4: Добавление записей для не-админов
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m22s
2026-02-09 14:41:39 +03:00
poignatov
2128e1b69c 5.0.2: Серая filled-иконка группировки в списке задач
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m2s
2026-02-09 14:24:47 +03:00
poignatov
5b53615d1a 5.0.1: Группа в форме задачи, next_show_at не меняется при редакт.
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m41s
2026-02-09 14:16:57 +03:00
poignatov
b05bd51b5b Вынос play-life-llm в отдельный скрипт runLLM.sh
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 21s
2026-02-09 12:24:37 +03:00
poignatov-home
9345b5ab5c Слияние с origin/main, сохранена локальная версия
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m35s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-08 17:04:00 +03:00
poignatov-home
bad198ce29 Первоначальный коммит
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-08 17:01:36 +03:00
poignatov
405d30bead 4.27.3: Фикс undefined в статистике Fitbit
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m7s
2026-02-06 21:17:47 +03:00
poignatov
d355928aa9 4.27.2: Улучшение отладки OAuth Fitbit
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m24s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-06 21:15:08 +03:00
poignatov
af2aaa4168 4.27.1: Фикс редиректа OAuth Fitbit
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m54s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-06 21:05:03 +03:00
poignatov
826996c5cd 4.27.0: Поднятие минор версии
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m6s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-06 20:59:56 +03:00
poignatov
dfccba4e55 Добавлена интеграция с Fitbit
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m25s
2026-02-06 20:50:49 +03:00
poignatov
f1c590de43 4.26.0: Добавление записи и улучшения
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m19s
2026-02-06 19:28:49 +03:00
poignatov
9f37d8b518 4.25.0: Группы вместо проектов для задач и желаний
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m31s
2026-02-06 17:42:36 +03:00
poignatov
0275d9aecf 4.24.7: Исправлена навигация при редактировании задачи
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m6s
2026-02-06 16:30:54 +03:00
poignatov
3ce408a6b1 4.24.6: Улучшен дизайн карточек отслеживания
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m5s
2026-02-06 16:11:18 +03:00
poignatov
e89f0879c6 4.24.5: Отступ снизу в модальном окне желания
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m4s
2026-02-06 16:08:25 +03:00
poignatov
73ce74bc7c 4.24.4: Исправлено обновление недели в отслеживании
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m3s
2026-02-06 16:02:49 +03:00
poignatov
867e8803bd 4.24.3: Обновление данных экрана доступа
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m18s
2026-02-06 15:59:33 +03:00
poignatov
49eff37399 4.24.2: Обновление версии
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m17s
2026-02-06 15:31:57 +03:00
poignatov
8a036df1b4 4.24.1: Фильтрация проектов в отслеживании
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 1m50s
2026-02-06 15:22:09 +03:00
poignatov
65f21cd025 4.24.0: Удаление и сброс прогресса слов 2026-02-06 15:16:58 +03:00
poignatov
a76d1d40cb 4.23.1: Исправлен сброс дневных баллов в 0:00 2026-02-06 14:48:39 +03:00
poignatov
6e9e2db23e 4.23.0: Добавлено отслеживание и улучшен UI
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m31s
2026-02-05 18:36:14 +03:00
poignatov
d6d40f4f86 4.22.0: Табы в добавлении слов, форма по одному
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m10s
2026-02-05 16:19:53 +03:00
poignatov
9c814d62b2 4.21.1: Уменьшен шрифт заголовков групп
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m11s
2026-02-05 13:55:32 +03:00
poignatov
9a066c88ac 4.21.0: Исправление навигации и истории
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m15s
2026-02-05 13:52:13 +03:00
poignatov
22f6807eb2 4.20.7: Корректировка отступов в модальном окне желания
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m10s
2026-02-05 13:03:17 +03:00
poignatov
59d376b999 4.20.6: Исправлено удаление фото в желаниях
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m28s
2026-02-05 12:59:16 +03:00
poignatov
0463c237c0 4.20.5: Кнопка назад для окна переноса
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m12s
2026-02-05 12:49:46 +03:00
poignatov
0ee689151e 4.20.4: Фоновая загрузка данных прогресса недель
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m8s
2026-02-05 12:40:44 +03:00
poignatov
126f9ec919 4.20.3: Дата next_show_at в целях-задачах
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m37s
2026-02-05 12:33:16 +03:00
poignatov
736f08887a 4.20.2: Клик по целям-задачам в желаниях
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m42s
2026-02-05 12:10:32 +03:00
poignatov
106defc3af 4.20.1: Детализация задачи из желания
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m45s
2026-02-05 11:57:10 +03:00
poignatov
42cf825de1 4.20.0: Sticky заголовки проектов
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 2m6s
2026-02-04 21:32:23 +03:00
poignatov
a60bfe97dc 4.19.0: Добавлены позиции подзадач
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m34s
2026-02-04 21:21:07 +03:00
poignatov
09ab87b6dd 4.18.0: Улучшен календарь выбора даты
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m33s
2026-02-04 21:13:29 +03:00
poignatov
f5e10c143f 4.17.1: Улучшение дизайна модального окна желания
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m9s
2026-02-04 20:38:51 +03:00
poignatov
8965e43341 4.17.0: Остаток баллов, одна строка заголовка
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m3s
2026-02-04 18:14:48 +03:00
poignatov
62d36dca17 4.16.3: Добавлен normalized_total_score в прогресс недель
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m32s
2026-02-04 17:42:58 +03:00
poignatov
e3e9084792 4.16.2: Упрощение админ-панели
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m18s
2026-02-04 17:26:52 +03:00
poignatov
f1ee6082dd 4.16.1: Улучшение отображения желаний в группах
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m29s
2026-02-04 17:24:06 +03:00
poignatov
479ffb2483 4.16.0: Добавлен выбор цвета для проектов
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 2m9s
2026-02-04 17:12:21 +03:00
poignatov
c22e56e68a 4.15.0: Добавлена принадлежность желаний к проектам
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m33s
2026-02-04 15:46:05 +03:00
poignatov
b9482dc86d 4.14.1: Исправлен z-index модальных окон
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m24s
2026-02-04 15:13:11 +03:00
poignatov
e66a3cecce 4.14.0: Добавлен дневной прирост в карточках
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m25s
2026-02-04 15:04:58 +03:00
poignatov
8023319ee4 4.13.6: Рефакторинг архитектуры табов
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m17s
2026-02-04 14:35:47 +03:00
poignatov
794947ea89 4.13.5: Исправлена фильтрация нодов в записях
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 2m2s
2026-02-04 14:20:29 +03:00
poignatov
cdd10d50c0 4.13.4: Клик по заголовку раскрывает задачи
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m12s
2026-02-04 14:17:03 +03:00
poignatov
43df4d76ce 4.13.3: Исправлена обработка кнопки назад
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 2m2s
2026-02-04 14:09:48 +03:00
poignatov
a169da9387 4.13.2: Сброс выбора дня при выходе с экрана прогресса
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m23s
2026-02-04 13:31:54 +03:00
poignatov
e0ffefc904 4.13.1: Исправлен двойной border у outline кружков
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m21s
2026-02-04 13:26:23 +03:00
poignatov
df3cced995 4.13.0: Добавлено удаление записей в статистике
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m22s
2026-02-03 18:42:44 +03:00
poignatov
36dd96976f 4.12.0: Добавлены записи за день на экране статистики
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m28s
2026-02-03 18:26:21 +03:00
poignatov
c3d366b9c2 4.11.0: Редактирование экрана статистики
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m1s
2026-02-03 17:19:25 +03:00
poignatov
0c5f7fa9d9 4.10.2: Синхронизация теней кнопок доски
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m5s
2026-02-03 16:45:35 +03:00
poignatov
23184f4b66 4.10.1: Выровнены отступы иконки поиска
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m5s
2026-02-03 16:16:26 +03:00
poignatov
b65dc30a9b 4.10.0: Добавлен поиск задач с иконкой
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m1s
2026-02-03 16:10:38 +03:00
poignatov
0162db46b3 4.9.0: Модальное окно детализации задачи
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m4s
2026-02-03 16:02:00 +03:00
poignatov
6b95326a86 Замена русских слов на английские в уведомлениях
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 17s
2026-02-03 15:34:16 +03:00
poignatov
56da114210 Обновлены уведомления CI/CD с этапом Регистрация
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 18s
2026-02-03 15:32:14 +03:00
poignatov
d90df473a2 4.8.11: Исправлена конфигурация PWA кэша
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m9s
2026-02-03 15:05:46 +03:00
poignatov
78ef1e78dc 4.8.10: Срок жизни кэша PWA 1 час
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 40s
2026-02-03 14:59:33 +03:00
poignatov
c3d2c0d6a6 4.8.9: Сортировка задач по completed и next_show_at
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m29s
2026-02-03 14:53:45 +03:00
poignatov
ff9fec7d7a 4.8.8: Бесконечные задачи в completed
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m20s
2026-02-03 14:03:19 +03:00
poignatov
56e29230ff 4.8.7: Удалённые задачи не отображаются в желаниях
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m35s
2026-02-03 13:55:31 +03:00
poignatov
ebe71f073c 4.8.6: Исправлена сортировка заблокированных желаний
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m20s
2026-02-02 20:59:37 +03:00
poignatov
8ffbfc6afd 4.8.5: Исправление деплоя на Synology
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m11s
2026-02-02 20:49:25 +03:00
poignatov
f34d35febf 4.8.4: Исправление форматирования Telegram уведомлений
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 1m3s
2026-02-02 20:45:51 +03:00
poignatov
fc7464021e Удален DEPLOY_SETUP.md
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 1m1s
2026-02-02 20:42:57 +03:00
poignatov
f7d340fc70 4.8.3: Автодеплой на Synology
Some checks are pending
Build and Push Docker Image / build-and-push (push) Has started running
2026-02-02 20:42:26 +03:00
poignatov
763b13358e 4.8.2: Улучшены отступы в форме редактирования цели
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m8s
2026-02-02 19:53:58 +03:00
poignatov
de29e3f602 4.8.1: Улучшена сортировка и отступы
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 58s
2026-02-02 19:41:42 +03:00
poignatov
a780b46175 4.8.0: Улучшен UI списка задач
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m4s
2026-02-02 19:38:36 +03:00
poignatov
3278eef2c5 4.7.3: Обратная сортировка заблокированных целей
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m17s
2026-02-02 19:30:39 +03:00
poignatov
5ac3c931b9 4.7.2: Упрощение отображения прогресса
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 51s
2026-02-02 19:24:50 +03:00
poignatov
89e66d6093 4.7.1: Фикс открытия админ-панели
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m11s
2026-02-02 19:16:49 +03:00
poignatov
b15e1dd615 4.7.0: Увеличены бонусы и множители
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m42s
2026-02-02 18:56:19 +03:00
poignatov
dfe9f5b9a0 4.6.1: Медиана из 4 недель вместо 12
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m22s
2026-02-02 18:38:25 +03:00
poignatov
2428ca5fd0 4.6.0: Расчет срока разблокировки желаний
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m39s
2026-01-31 18:43:25 +03:00
poignatov
e955494dc8 4.5.0: Улучшена работа с задачами желаний
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m25s
2026-01-30 19:53:13 +03:00
poignatov
25f0c2697a 4.4.1: Иконка молнии для задач-желаний
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 52s
2026-01-29 18:18:36 +03:00
poignatov
56d413f761 4.4.0: Улучшения UI детализации задачи
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m0s
2026-01-29 18:02:11 +03:00
poignatov
f266508d04 4.3.0: Автовыполнение задач в конце дня
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m11s
2026-01-29 17:47:47 +03:00
poignatov
5c5fc07481 4.2.2: Исправлена проверка доступа к желанию
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m28s
2026-01-29 16:00:17 +03:00
poignatov
4e270cb322 4.2.1: Изменена сортировка желаний
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m31s
2026-01-29 15:54:04 +03:00
poignatov
ba0f34c91b 4.2.0: Драфты задач и автовыполнение
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 2m29s
2026-01-28 20:19:53 +03:00
poignatov
a886cf13e8 4.1.2: Исправлен формат Telegram сообщений
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 48s
2026-01-27 21:49:10 +03:00
poignatov
8e29acd25e Исправлен формат Telegram сообщений
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 13s
2026-01-27 21:48:29 +03:00
poignatov
6dbb0f8d90 Обновление gitea workflow: сборка всегда
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m11s
2026-01-27 19:40:09 +03:00
poignatov
6174475509 4.1.1: Отключен поворот экрана в PWA 2026-01-27 19:31:46 +03:00
156 changed files with 26845 additions and 2859 deletions

BIN
.DS_Store vendored

Binary file not shown.

8
.claude/settings.json Normal file
View File

@@ -0,0 +1,8 @@
{
"permissions": {
"allow": [
"Bash(curl -s -o /dev/null -w \"%{http_code}\" http://localhost:8080/priorities/confirm)",
"Bash(curl -s -o /dev/null -w \"%{http_code}\" -X POST http://localhost:8080/priorities/confirm -H \"Content-Type: application/json\" -d '[]')"
]
}
}

View File

@@ -14,6 +14,13 @@
"type": "shell",
"cwd": "${workspaceFolder}"
},
{
"name": "runLLM",
"description": "Запуск/перезапуск play-life-llm (обычно на отдельной машине)",
"command": "./runLLM.sh",
"type": "shell",
"cwd": "${workspaceFolder}"
},
{
"name": "backupFromProd",
"description": "Создание дампа базы данных с продакшена",

View File

@@ -0,0 +1,37 @@
---
name: normalized_total_score fix migration
overview: "Новая миграция 000023: пересоздать weekly_report_mv с max_goal_score и удалить max_score из weekly_goals."
todos:
- id: migration-up
content: "Добавить 000023 up: DROP MV, CREATE MV с max_goal_score (из 000020), DROP COLUMN IF EXISTS max_score"
status: pending
- id: migration-down
content: "Добавить 000023 down: восстановить MV со старой формулой (max_score) и колонку max_score"
status: pending
- id: verify-local
content: Применить миграцию локально и проверить Релокация 2026-08 (normalized 32.74 и 21.55)
status: pending
isProject: false
---
# План: Исправить normalized_total_score через новую миграцию
## Проблема
На проде (и в локальной копии продовой БД) `normalized_total_score` не учитывает `max_goal_score`: в определении материализованного представления `weekly_report_mv` до сих пор используется колонка `wg.max_score`, которая не заполняется (всегда NULL) → формула всегда даёт `normalized_total_score = total_score`.
## Решение
Новая миграция (не менять 000020/000022):
1. **Пересоздать `weekly_report_mv**` с определением из 000020: в формуле использовать `max_goal_score`, тот же подзапрос по `n.created_date` и фильтр «только прошлые недели».
2. **Удалить колонку `max_score` из `weekly_goals**`, если есть: `ALTER TABLE weekly_goals DROP COLUMN IF EXISTS max_score;`
После применения и `REFRESH` (или при следующем кроне) для прошлых недель normalized будет ограничиваться целями (например, Релокация 2026-08: 39.14 → 32.74).
## Todos
- **migration-up** — Добавить миграцию `000023_fix_weekly_report_mv_use_max_goal_score.up.sql`: DROP MV, CREATE MV с max_goal_score (копия определения из 000020), DROP COLUMN IF EXISTS max_score в weekly_goals
- **migration-down** — Добавить `000023_fix_weekly_report_mv_use_max_goal_score.down.sql`: восстановить MV со старой формулой (max_score) и колонку max_score в weekly_goals
- **verify-local** — Применить миграцию локально и проверить по Релокации за 2026-08: normalized_total_score = 32.74 (project_id 27) и 21.55 (project_id 592)

View File

@@ -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 приложение (с пересборкой)
- База данных

View File

@@ -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 приложение (с пересборкой)
- База данных

View File

@@ -13,4 +13,6 @@ alwaysApply: true
- React компонентами и стилями в `play-life-web/src/`
- Docker конфигурациями (`docker-compose.yml`, `Dockerfile`)
**Команда для перезапуска:** `./run.sh` или `bash run.sh` в корне проекта.
При изменениях в `play-life-llm/` (если LLM запущен на этой машине) выполни `./runLLM.sh`.
**Команды для перезапуска:** `./run.sh` (web + backend + db) или `bash run.sh` в корне проекта. Для LLM на этой машине: `./runLLM.sh`.

View File

@@ -36,29 +36,39 @@ jobs:
fi
- name: Patch DNS for Local Network
if: steps.version_check.outputs.changed == 'true'
run: |
# Записываем IP Synology прямо в контейнер сборки
echo "192.168.50.55 dungeonsiege.synology.me" | sudo tee -a /etc/hosts
- name: Log in to Gitea Registry
if: steps.version_check.outputs.changed == 'true'
run: |
echo "${{ secrets.GIT_TOKEN }}" | docker login dungeonsiege.synology.me -u ${{ secrets.GIT_USERNAME }} --password-stdin
- name: Build and Push
- name: Build Docker Image
id: build
if: steps.version_check.outputs.changed == 'true'
run: |
REGISTRY="dungeonsiege.synology.me/poignatov/play-life"
VER="${{ steps.version_check.outputs.current }}"
echo "Building Docker image..."
echo "Registry: $REGISTRY"
echo "Tags: latest, $VER"
echo "Tag: latest"
# Собираем один раз
docker build -t $REGISTRY:latest -t $REGISTRY:$VER .
# Собираем образ
docker build -t $REGISTRY:latest .
echo "✅ Successfully built image: $REGISTRY:latest"
- name: Log in to Gitea Registry
if: steps.version_check.outputs.changed == 'true'
run: |
echo "${{ secrets.GIT_TOKEN }}" | docker login dungeonsiege.synology.me -u ${{ secrets.GIT_USERNAME }} --password-stdin
- name: Push Docker Image
id: push
if: steps.version_check.outputs.changed == 'true'
run: |
REGISTRY="dungeonsiege.synology.me/poignatov/play-life"
VER="${{ steps.version_check.outputs.current }}"
# Тегируем образ версией
docker tag $REGISTRY:latest $REGISTRY:$VER
# Пушим оба тега
echo "Pushing image to registry..."
@@ -69,35 +79,137 @@ jobs:
echo " - $REGISTRY:latest"
echo " - $REGISTRY:$VER"
- name: Send Telegram notification (success)
if: success() && steps.version_check.outputs.changed == 'true'
- name: Send Telegram notification (build success)
if: success() && steps.version_check.outputs.changed == 'false'
uses: appleboy/telegram-action@master
with:
to: ${{ secrets.TELEGRAM_TO }}
token: ${{ secrets.TELEGRAM_TOKEN }}
format: markdown
message: |
✅ Успешная публикация play-life!
*play-life*
`${{ steps.version_check.outputs.commit_message }}`
${{ steps.version_check.outputs.commit_message }}
Build: ✅
Registration: ⏭️
Deploy: ⏭️
- name: Send Telegram notification (failure)
if: failure()
- name: Deploy to Production Server
id: deploy
if: steps.version_check.outputs.changed == 'true'
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
password: ${{ secrets.DEPLOY_PASSWORD }}
script: |
set -e
# Расширяем PATH для Synology (при SSH сессии PATH минимальный)
export PATH="/usr/local/bin:/usr/syno/bin:$PATH"
REGISTRY="dungeonsiege.synology.me/poignatov/play-life"
DEPLOY_PATH="/volume1/docker/play-life"
echo "🚀 Начинаю деплой на production сервер..."
echo "PATH: $PATH"
# Проверяем наличие docker
if ! command -v docker >/dev/null 2>&1; then
echo "❌ Docker не найден в PATH!"
echo "Пробуем найти docker..."
which docker || find /usr -name "docker" -type f 2>/dev/null | head -5
exit 1
fi
DOCKER_CMD="docker"
# Определяем docker-compose (может быть docker compose или docker-compose)
if command -v docker-compose >/dev/null 2>&1; then
DOCKER_COMPOSE_CMD="docker-compose"
elif docker compose version >/dev/null 2>&1; then
DOCKER_COMPOSE_CMD="docker compose"
else
echo "❌ Docker Compose не найден!"
exit 1
fi
echo "Используем: $DOCKER_CMD и $DOCKER_COMPOSE_CMD"
# Переходим в директорию проекта
cd $DEPLOY_PATH
# Логинимся в registry
echo "${{ secrets.GIT_TOKEN }}" | $DOCKER_CMD login dungeonsiege.synology.me -u ${{ secrets.GIT_USERNAME }} --password-stdin
# Обновляем образ
echo "📥 Обновляю образ из registry..."
$DOCKER_CMD pull $REGISTRY:latest
# Перезапускаем контейнеры
echo "🔄 Перезапускаю контейнеры..."
$DOCKER_COMPOSE_CMD -f docker-compose.prod.yml up -d --force-recreate
# Проверяем статус
echo "✅ Деплой завершен успешно"
$DOCKER_COMPOSE_CMD -f docker-compose.prod.yml ps
- name: Send Telegram notification (publish success)
if: steps.build.outcome == 'success' && steps.version_check.outputs.changed == 'true' && steps.push.outcome == 'success' && steps.deploy.outcome == 'success'
uses: appleboy/telegram-action@master
with:
to: ${{ secrets.TELEGRAM_TO }}
token: ${{ secrets.TELEGRAM_TOKEN }}
format: markdown
message: |
❌ Ошибка сборки или публикации play-life!
*play-life*
`${{ steps.version_check.outputs.commit_message }}`
${{ steps.version_check.outputs.commit_message }}
Build: ✅
Registration: ✅
Deploy: ✅
- name: Send Telegram notification (skipped)
if: steps.version_check.outputs.changed == 'false'
- name: Send Telegram notification (push failure)
if: steps.build.outcome == 'success' && steps.version_check.outputs.changed == 'true' && steps.push.outcome == 'failure'
uses: appleboy/telegram-action@master
with:
to: ${{ secrets.TELEGRAM_TO }}
token: ${{ secrets.TELEGRAM_TOKEN }}
format: markdown
message: |
Пропущена публикация play-life
*play-life*
`${{ steps.version_check.outputs.commit_message }}`
${{ steps.version_check.outputs.commit_message }}
Build: ✅
Registration: ❌
Deploy: ⏭️
- name: Send Telegram notification (deploy failure)
if: steps.build.outcome == 'success' && steps.push.outcome == 'success' && steps.version_check.outputs.changed == 'true' && steps.deploy.outcome == 'failure'
uses: appleboy/telegram-action@master
with:
to: ${{ secrets.TELEGRAM_TO }}
token: ${{ secrets.TELEGRAM_TOKEN }}
format: markdown
message: |
*play-life*
`${{ steps.version_check.outputs.commit_message }}`
Build: ✅
Registration: ✅
Deploy: ❌
- name: Send Telegram notification (build failure)
if: steps.build.outcome == 'failure'
uses: appleboy/telegram-action@master
with:
to: ${{ secrets.TELEGRAM_TO }}
token: ${{ secrets.TELEGRAM_TOKEN }}
format: markdown
message: |
*play-life*
`${{ steps.version_check.outputs.commit_message }}`
Build: ❌
Registration: ⏭️
Deploy: ⏭️

3
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"claudeCode.allowDangerouslySkipPermissions": true
}

19
.vscode/tasks.json vendored
View File

@@ -39,6 +39,25 @@
"problemMatcher": [],
"detail": "Перезапуск Play Life: перезапуск всех контейнеров"
},
{
"label": "runLLM",
"type": "shell",
"command": "./runLLM.sh",
"group": {
"kind": "build",
"isDefault": false
},
"presentation": {
"echo": true,
"reveal": "always",
"focus": false,
"panel": "shared",
"showReuseMessage": true,
"clear": false
},
"problemMatcher": [],
"detail": "Запуск/перезапуск play-life-llm (обычно на отдельной машине)"
},
{
"label": "backupFromProd",
"type": "shell",

65
CLAUDE.md Normal file
View File

@@ -0,0 +1,65 @@
# Правила проекта
## Миграции базы данных
**ВАЖНО:** Если меняется структура базы данных — пиши НОВУЮ миграцию.
НИ В КОЕМ СЛУЧАЕ не меняй старые миграции, можно добавлять только новые.
Старой миграцией считается та, что была уже ранее закоммичена.
## Перезапуск приложения после изменений
После применения всех изменений в бэкенде (`play-life-backend/`) или фронтенде (`play-life-web/`), а также после изменений в `docker-compose.yml`, **ОБЯЗАТЕЛЬНО** выполни команду `./run.sh` для перезапуска всех сервисов.
Применяется при работе с:
- Go кодом в `play-life-backend/`
- Миграциями базы данных в `play-life-backend/migrations/`
- React компонентами и стилями в `play-life-web/src/`
- Docker конфигурациями (`docker-compose.yml`, `Dockerfile`)
При изменениях в `play-life-llm/` (если LLM запущен на этой машине) выполни `./runLLM.sh`.
**Команды:** `./run.sh` (web + backend + db) или `bash run.sh` в корне проекта. Для LLM: `./runLLM.sh`.
## Поднятие версии и пуш
Когда пользователь просит **поднять версию и запушить**:
### 1. Определи тип версии
- **major** — первая цифра (1.1.1 → 2.0.0), минор и патч обнуляются
- **minor** — вторая цифра (1.0.1 → 1.1.0), патч обнуляется
- **patch** — третья цифра (1.0.0 → 1.0.1)
Любая часть версии может быть больше 9 (10, 11, 12 и т.д.).
**Если тип версии непонятен из контекста — обязательно спроси у пользователя!**
### 2. Обнови версию в файлах
- `VERSION` (в корне проекта)
- `play-life-web/package.json` (поле `"version"`)
### 3. Составь commit message
Выполни `git diff --staged` и `git diff`, проанализируй изменения. Составь **короткий commit message** (максимум 50 символов) на русском языке. Формат: `"1.2.3: Описание изменений"`.
### 4. Закоммить и запушить
```bash
git add -A
git commit -m "<commit message>"
git push
```
## Пуш без поднятия версии
Когда пользователь просит просто запушить (без поднятия версии):
1. Выполни `git diff --staged` и `git diff`, составь короткий commit message на русском (максимум 50 символов)
2. `git add -A && git commit -m "<commit message>" && git push`
**Примеры:**
- "Подними патч и запушь" → поднять patch
- "Bump minor and push" → поднять minor
- "Подними версию и запушь" → спросить какой тип
- "Запушь изменения" → пушить без изменения версии

View File

@@ -1 +1 @@
4.1.0
6.27.3

75
check-repo-fs.sh Executable file
View File

@@ -0,0 +1,75 @@
#!/bin/bash
# Скрипт для проверки файловой системы репозитория Gitea
# Выполните на сервере с административным доступом
REPO_PATH="/poignatov/play-life.git"
GITEA_USER="git" # или пользователь, под которым работает Gitea
echo "=== Проверка существования репозитория ==="
if [ -d "$REPO_PATH" ]; then
echo "✓ Репозиторий существует"
else
echo "✗ Репозиторий НЕ найден: $REPO_PATH"
exit 1
fi
echo ""
echo "=== Проверка прав доступа ==="
ls -ld "$REPO_PATH"
echo ""
echo "=== Проверка владельца ==="
OWNER=$(stat -c '%U:%G' "$REPO_PATH" 2>/dev/null || stat -f '%Su:%Sg' "$REPO_PATH" 2>/dev/null)
echo "Владелец: $OWNER"
echo ""
echo "=== Проверка размера репозитория ==="
du -sh "$REPO_PATH"
echo ""
echo "=== Проверка свободного места ==="
df -h "$REPO_PATH" | tail -1
echo ""
echo "=== Проверка ключевых файлов Git ==="
if [ -f "$REPO_PATH/config" ]; then
echo "✓ config существует"
else
echo "✗ config НЕ найден"
fi
if [ -d "$REPO_PATH/objects" ]; then
echo "✓ objects/ существует"
echo " Количество объектов: $(find "$REPO_PATH/objects" -type f | wc -l)"
else
echo "✗ objects/ НЕ найден"
fi
if [ -f "$REPO_PATH/HEAD" ]; then
echo "✓ HEAD существует"
echo " Текущая ветка: $(cat "$REPO_PATH/HEAD")"
else
echo "✗ HEAD НЕ найден"
fi
if [ -f "$REPO_PATH/refs/heads/main" ]; then
echo "✓ refs/heads/main существует"
echo " Последний коммит: $(cat "$REPO_PATH/refs/heads/main")"
else
echo "✗ refs/heads/main НЕ найден"
fi
echo ""
echo "=== Проверка целостности репозитория ==="
cd "$REPO_PATH"
if git fsck --no-progress 2>&1 | head -20; then
echo "✓ Репозиторий цел"
else
echo "✗ Обнаружены проблемы с целостностью"
fi
echo ""
echo "=== Проверка логов Gitea ==="
echo "Проверьте логи Gitea на наличие ошибок:"
echo " - /var/log/gitea/gitea.log"
echo " - или в директории, указанной в конфиге Gitea"

27
docker-compose.prod.yml Normal file
View File

@@ -0,0 +1,27 @@
version: '3.8'
# Production конфигурация для Synology
# Использует образ из registry вместо локальной сборки
# База данных postgres запущена отдельно (не в этом compose)
services:
play-life:
image: dungeonsiege.synology.me/poignatov/play-life:latest
container_name: play-life-prod
ports:
- "3080:80"
volumes:
- /volume1/docker/play-life/uploads:/app/uploads:rw
restart: always
env_file:
- .env
# Подключаемся к общей сети playlife-net
# Перед первым запуском нужно создать сеть и подключить postgres:
# docker network create playlife-net
# docker network connect playlife-net postgres1
networks:
- playlife-net
networks:
playlife-net:
external: true

View File

@@ -14,6 +14,8 @@ services:
POSTGRES_DB: ${DB_NAME:-playeng}
ports:
- "${DB_PORT:-5432}:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-playeng}"]
interval: 10s
@@ -59,6 +61,22 @@ services:
env_file:
- .env
# LLM сервис (Ollama + Tavily), свой Docker и свой env
llm:
build:
context: ./play-life-llm
dockerfile: Dockerfile
container_name: play-life-llm
ports:
- "8090:8090"
restart: unless-stopped
env_file:
- ./play-life-llm/.env
volumes:
postgres_data:
name: play-life_postgres_data
networks:
default:
name: play-life-network

View File

@@ -62,6 +62,26 @@ TODOIST_CLIENT_SECRET=
# Получить в Developer Console: "Client secret for webhooks"
TODOIST_WEBHOOK_SECRET=
# ============================================
# Fitbit Integration Configuration
# ============================================
# Fitbit приложение для интеграции с Play Life
# Настроить в: https://dev.fitbit.com/apps
#
# В настройках Fitbit приложения указать:
# - OAuth 2.0 Application Type: Server
# - Callback URL: <WEBHOOK_BASE_URL>/api/integrations/fitbit/oauth/callback
# - Default Access Type: Read-Only
# - Scopes: activity, profile
# - Terms of Service URL: <WEBHOOK_BASE_URL>/terms
# - Privacy Policy URL: <WEBHOOK_BASE_URL>/privacy
# Client ID Fitbit приложения
FITBIT_CLIENT_ID=
# Client Secret Fitbit приложения
FITBIT_CLIENT_SECRET=
# ============================================
# Authentication Configuration
# ============================================

View File

@@ -47,12 +47,12 @@ docker images | grep -E "postgres:(15|16|17|18|latest)" | awk '{print $3}' | xar
echo -e "${GREEN} ✅ Старые образы postgres удалены${NC}"
echo ""
# 2. Поднятие всех сервисов
# 2. Поднятие сервисов (без LLM — он обычно на отдельной машине, см. ./runLLM.sh)
echo -e "${YELLOW}2. Поднятие сервисов через Docker Compose...${NC}"
echo " - База данных PostgreSQL 18.0 (порт: $DB_PORT)"
echo " - Backend сервер (порт: $PORT)"
echo " - Frontend приложение (порт: $WEB_PORT)"
docker-compose up -d --build
docker-compose up -d --build db backend play-life-web
echo -e "${GREEN} ✅ Сервисы запущены${NC}"
echo ""

View File

@@ -49,8 +49,34 @@ server {
proxy_cache_bypass $http_upgrade;
}
# Proxy admin panel to backend (must be before location /)
location ^~ /admin {
proxy_pass http://localhost:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
# Proxy project endpoints to backend (must be before location /)
location ^~ /project/ {
proxy_pass http://localhost:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
# Proxy other API endpoints to backend
location ~ ^/(playlife-feed|d2dc349a-0d13-49b2-a8f0-1ab094bfba9b|projects|project/priority|project/move|project/delete|project/create|message/post|weekly_goals/setup|admin|admin\.html)$ {
location ~ ^/(playlife-feed|d2dc349a-0d13-49b2-a8f0-1ab094bfba9b|projects|message/post|weekly_goals/setup|project_score_sample_mv/refresh|priorities/confirm)$ {
proxy_pass http://localhost:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;

View File

@@ -161,43 +161,55 @@
color: white;
}
.auth-error {
background: white;
padding: 30px;
border-radius: 10px;
text-align: center;
max-width: 500px;
margin: 50px auto;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
}
.auth-error h2 {
color: #f44336;
margin-bottom: 15px;
}
.auth-error p {
color: #666;
margin-bottom: 20px;
}
.auth-error a {
display: inline-block;
padding: 10px 20px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
text-decoration: none;
border-radius: 5px;
font-weight: 600;
}
.auth-error a:hover {
opacity: 0.9;
}
</style>
</head>
<body>
<div class="container">
<div id="authErrorContainer" style="display: none;">
<div class="auth-error">
<h2>⚠️ Требуется авторизация</h2>
<p id="authErrorMessage">Для доступа к админ-панели необходимо войти в систему как администратор.</p>
<a href="/" target="_self">Перейти на главную страницу</a>
</div>
</div>
<div class="container" id="mainContainer">
<h1>🎯 Play Life Backend - Admin Panel</h1>
<div class="grid">
<!-- Message Post Card -->
<div class="card">
<h2>
📨 Message Post
<span class="status" id="messageStatus" style="display: none;"></span>
</h2>
<textarea id="messageText" placeholder="Введите сообщение с паттернами **Project+10.5** или **Project-5.0**...
Пример:
Сегодня работал над проектами:
**Frontend+15.5**
**Backend+8.0**
**Design-2.5**"></textarea>
<button onclick="sendMessage()">Отправить сообщение</button>
<div id="messageResult"></div>
</div>
<!-- Daily Report Trigger Card -->
<div class="card">
<h2>
📈 Daily Report Trigger
<span class="status" id="dailyReportStatus" style="display: none;"></span>
</h2>
<p style="margin-bottom: 15px; color: #666;">
Нажмите кнопку для отправки ежедневного отчёта по Score и Целям в Telegram (обычно отправляется автоматически в 23:59).
</p>
<button onclick="triggerDailyReport()">Отправить отчёт</button>
<div id="dailyReportResult"></div>
</div>
<!-- Weekly Goals Setup Card -->
<div class="card">
<h2>
@@ -210,16 +222,80 @@
<button onclick="setupWeeklyGoals()">Обновить цели</button>
<div id="goalsResult"></div>
</div>
<!-- Project score sample MV Card -->
<div class="card">
<h2>
📊 project_score_sample_mv
<span class="status" id="mvStatus" style="display: none;"></span>
</h2>
<p style="margin-bottom: 15px; color: #666;">
Обновить материализованное представление и показать данные текущего пользователя (по одному представителю на вариант баллов проекта).
</p>
<button onclick="refreshProjectScoreSampleMv()">Обновить project_score_sample_mv</button>
<div id="mvResult"></div>
</div>
</div>
</div>
<script>
// Получаем токен из localStorage
function getAuthToken() {
return localStorage.getItem('access_token');
}
// Проверяем авторизацию при загрузке страницы
function checkAuth() {
const token = getAuthToken();
if (!token) {
showAuthError('Токен авторизации не найден. Пожалуйста, войдите в систему.');
return false;
}
return true;
}
// Показываем сообщение об ошибке авторизации
function showAuthError(message) {
document.getElementById('authErrorContainer').style.display = 'block';
document.getElementById('mainContainer').style.display = 'none';
document.getElementById('authErrorMessage').textContent = message;
}
// Обрабатываем ошибки авторизации
function handleAuthError(response) {
if (response.status === 401) {
showAuthError('Сессия истекла. Пожалуйста, войдите в систему снова.');
return true;
} else if (response.status === 403) {
showAuthError('У вас нет прав доступа к админ-панели. Требуются права администратора.');
return true;
}
return false;
}
// Получаем заголовки с авторизацией
function getAuthHeaders() {
const token = getAuthToken();
const headers = {
'Content-Type': 'application/json',
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
return headers;
}
function getApiUrl() {
// Автоматически определяем URL текущего хоста
// Админка обслуживается тем же бекендом, поэтому используем текущий origin
return window.location.origin;
}
// Проверяем авторизацию при загрузке страницы
if (!checkAuth()) {
// Страница уже скрыта в checkAuth
}
function showStatus(elementId, status, text) {
const statusEl = document.getElementById(elementId);
statusEl.textContent = text;
@@ -254,44 +330,6 @@
resultEl.appendChild(div);
}
async function sendMessage() {
const text = document.getElementById('messageText').value.trim();
if (!text) {
alert('Пожалуйста, введите сообщение');
return;
}
showStatus('messageStatus', 'loading', 'Отправка...');
showResult('messageResult', null, false, true);
try {
const response = await fetch(`${getApiUrl()}/message/post`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
body: {
text: text
}
})
});
const data = await response.json();
if (response.ok) {
showStatus('messageStatus', 'success', 'Успешно');
showResult('messageResult', data, false);
} else {
showStatus('messageStatus', 'error', 'Ошибка');
showResult('messageResult', data, true);
}
} catch (error) {
showStatus('messageStatus', 'error', 'Ошибка');
showResult('messageResult', { error: error.message }, true);
}
}
async function setupWeeklyGoals() {
showStatus('goalsStatus', 'loading', 'Обновление...');
showResult('goalsResult', null, false, true);
@@ -299,11 +337,13 @@
try {
const response = await fetch(`${getApiUrl()}/weekly_goals/setup`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
}
headers: getAuthHeaders()
});
if (handleAuthError(response)) {
return;
}
const data = await response.json();
if (response.ok) {
@@ -319,39 +359,35 @@
}
}
async function triggerDailyReport() {
showStatus('dailyReportStatus', 'loading', 'Отправка...');
showResult('dailyReportResult', null, false, true);
async function refreshProjectScoreSampleMv() {
showStatus('mvStatus', 'loading', 'Обновление...');
showResult('mvResult', null, false, true);
try {
const response = await fetch(`${getApiUrl()}/daily-report/trigger`, {
const response = await fetch(`${getApiUrl()}/project_score_sample_mv/refresh`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
}
headers: getAuthHeaders()
});
if (handleAuthError(response)) {
return;
}
const data = await response.json();
if (response.ok) {
showStatus('dailyReportStatus', 'success', 'Успешно');
showResult('dailyReportResult', data, false);
showStatus('mvStatus', 'success', 'Успешно');
showResult('mvResult', data, false);
} else {
showStatus('dailyReportStatus', 'error', 'Ошибка');
showResult('dailyReportResult', data, true);
showStatus('mvStatus', 'error', 'Ошибка');
showResult('mvResult', data, true);
}
} catch (error) {
showStatus('dailyReportStatus', 'error', 'Ошибка');
showResult('dailyReportResult', { error: error.message }, true);
showStatus('mvStatus', 'error', 'Ошибка');
showResult('mvResult', { error: error.message }, true);
}
}
// Разрешаем отправку формы по Enter (Ctrl+Enter для textarea)
document.getElementById('messageText').addEventListener('keydown', function(e) {
if (e.ctrlKey && e.key === 'Enter') {
sendMessage();
}
});
</script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,7 @@
-- Migration: Remove task drafts tables
-- Date: 2026-01-26
--
-- This migration removes tables created for task drafts
DROP TABLE IF EXISTS task_draft_subtasks;
DROP TABLE IF EXISTS task_drafts;

View File

@@ -0,0 +1,45 @@
-- Migration: Add task drafts tables
-- Date: 2026-01-26
--
-- This migration creates tables for storing task drafts:
-- 1. task_drafts - main table for task drafts with progression value and auto_complete flag
-- 2. task_draft_subtasks - stores only checked subtask IDs for each draft
-- ============================================
-- Table: task_drafts
-- ============================================
CREATE TABLE task_drafts (
id SERIAL PRIMARY KEY,
task_id INTEGER REFERENCES tasks(id) ON DELETE CASCADE,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
progression_value NUMERIC(10,4),
auto_complete BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
UNIQUE(task_id)
);
CREATE INDEX idx_task_drafts_task_id ON task_drafts(task_id);
CREATE INDEX idx_task_drafts_user_id ON task_drafts(user_id);
CREATE INDEX idx_task_drafts_auto_complete ON task_drafts(auto_complete) WHERE auto_complete = TRUE;
COMMENT ON TABLE task_drafts IS 'Stores draft states for tasks with progression value and auto-complete flag';
COMMENT ON COLUMN task_drafts.progression_value IS 'Saved progression value from user input';
COMMENT ON COLUMN task_drafts.auto_complete IS 'Flag indicating task should be auto-completed at end of day (23:55)';
COMMENT ON COLUMN task_drafts.task_id IS 'Reference to task. UNIQUE constraint ensures one draft per task';
-- ============================================
-- Table: task_draft_subtasks
-- ============================================
CREATE TABLE task_draft_subtasks (
id SERIAL PRIMARY KEY,
task_draft_id INTEGER REFERENCES task_drafts(id) ON DELETE CASCADE,
subtask_id INTEGER REFERENCES tasks(id) ON DELETE CASCADE,
UNIQUE(task_draft_id, subtask_id)
);
CREATE INDEX idx_task_draft_subtasks_task_draft_id ON task_draft_subtasks(task_draft_id);
CREATE INDEX idx_task_draft_subtasks_subtask_id ON task_draft_subtasks(subtask_id);
COMMENT ON TABLE task_draft_subtasks IS 'Stores only checked subtask IDs for each draft. If subtask is not in this table, it means it is unchecked';
COMMENT ON COLUMN task_draft_subtasks.subtask_id IS 'Reference to subtask task. Only checked subtasks are stored here';

View File

@@ -0,0 +1,13 @@
-- Migration: Revert wishlist_id unique index fix
-- Date: 2026-01-30
--
-- This migration reverts the composite unique index back to the original
-- unique index that only checked wishlist_id.
-- Drop the composite unique index
DROP INDEX IF EXISTS idx_tasks_wishlist_id_user_id_unique;
-- Restore the original unique index on wishlist_id only
CREATE UNIQUE INDEX idx_tasks_wishlist_id_unique
ON tasks(wishlist_id)
WHERE wishlist_id IS NOT NULL AND deleted = FALSE;

View File

@@ -0,0 +1,16 @@
-- Migration: Fix wishlist_id unique index to allow multiple users
-- Date: 2026-01-30
--
-- This migration fixes the unique index on wishlist_id to allow multiple users
-- to create tasks for the same wishlist item. The old index only checked wishlist_id,
-- but now we need a composite unique index on (wishlist_id, user_id).
-- Drop the old unique index that only checked wishlist_id
DROP INDEX IF EXISTS idx_tasks_wishlist_id_unique;
-- Create a new composite unique index on (wishlist_id, user_id)
-- This allows multiple users to have tasks for the same wishlist item,
-- but prevents the same user from having multiple tasks for the same wishlist item
CREATE UNIQUE INDEX idx_tasks_wishlist_id_user_id_unique
ON tasks(wishlist_id, user_id)
WHERE wishlist_id IS NOT NULL AND deleted = FALSE;

View File

@@ -0,0 +1,4 @@
-- Migration: Drop projects_median_mv materialized view
-- Date: 2026-01-30
DROP MATERIALIZED VIEW IF EXISTS projects_median_mv;

View File

@@ -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.';

View File

@@ -0,0 +1,34 @@
-- Migration: Revert median calculation back to 12 weeks
-- Date: 2026-02-02
--
-- This migration reverts projects_median_mv back to using 12 weeks.
DROP MATERIALIZED VIEW IF EXISTS projects_median_mv;
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.';

View File

@@ -0,0 +1,35 @@
-- Migration: Change median calculation from 12 weeks to 4 weeks
-- Date: 2026-02-02
--
-- This migration updates projects_median_mv to calculate median based on
-- the last 4 weeks instead of 12 weeks.
DROP MATERIALIZED VIEW IF EXISTS projects_median_mv;
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 <= 4 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 4 weeks of historical data. Includes user_id for multi-tenant support.';

View File

@@ -0,0 +1,9 @@
-- Migration: Remove is_admin field from users table
-- Date: 2026-02-02
--
-- This migration reverts the addition of is_admin field.
DROP INDEX IF EXISTS idx_users_is_admin;
ALTER TABLE users
DROP COLUMN IF EXISTS is_admin;

View File

@@ -0,0 +1,12 @@
-- Migration: Add is_admin field to users table
-- Date: 2026-02-02
--
-- This migration adds is_admin boolean field to users table to identify admin users.
-- Default value is FALSE, so existing users will not become admins automatically.
ALTER TABLE users
ADD COLUMN is_admin BOOLEAN NOT NULL DEFAULT FALSE;
CREATE INDEX idx_users_is_admin ON users(is_admin);
COMMENT ON COLUMN users.is_admin IS 'Indicates if the user has admin privileges';

View File

@@ -0,0 +1,9 @@
-- Migration: Remove project_id field from wishlist_items table
-- Date: 2026-02-02
--
-- This migration reverts the addition of project_id field.
DROP INDEX IF EXISTS idx_wishlist_items_project_id;
ALTER TABLE wishlist_items
DROP COLUMN IF EXISTS project_id;

View File

@@ -0,0 +1,13 @@
-- Migration: Add project_id field to wishlist_items table
-- Date: 2026-02-02
--
-- This migration adds project_id field to wishlist_items table to allow
-- grouping wishlist items by project. The field is nullable, so existing
-- items without a project will remain valid.
ALTER TABLE wishlist_items
ADD COLUMN project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL;
CREATE INDEX idx_wishlist_items_project_id ON wishlist_items(project_id);
COMMENT ON COLUMN wishlist_items.project_id IS 'Project this wishlist item belongs to (optional)';

View File

@@ -0,0 +1,9 @@
-- Migration: Remove color field from projects table
-- Date: 2026-02-02
--
-- This migration removes the color field from projects table.
DROP INDEX IF EXISTS idx_projects_color;
ALTER TABLE projects
DROP COLUMN IF EXISTS color;

View File

@@ -0,0 +1,45 @@
-- Migration: Add color field to projects table
-- Date: 2026-02-02
--
-- This migration adds color field to projects table to allow
-- custom color selection for projects. The field is NOT NULL,
-- and existing projects will be assigned colors from a predefined palette.
-- Добавляем поле color
ALTER TABLE projects
ADD COLUMN color VARCHAR(7) NOT NULL DEFAULT '#3B82F6';
-- Палитра из 30 контрастных цветов (синхронизирована с backend и frontend)
-- Заполняем существующие проекты цветами из палитры
DO $$
DECLARE
colors TEXT[] := ARRAY[
'#EF4444', '#F97316', '#F59E0B', '#EAB308', '#84CC16',
'#22C55E', '#10B981', '#14B8A6', '#06B6D4', '#0EA5E9',
'#3B82F6', '#6366F1', '#8B5CF6', '#A855F7', '#D946EF',
'#EC4899', '#F43F5E', '#DC2626', '#EA580C', '#CA8A04',
'#65A30D', '#16A34A', '#059669', '#0D9488', '#0891B2',
'#0284C7', '#2563EB', '#4F46E5', '#7C3AED', '#9333EA'
];
project_record RECORD;
color_index INTEGER := 0;
BEGIN
-- Обновляем существующие проекты, присваивая им цвета из палитры
FOR project_record IN
SELECT id FROM projects ORDER BY id
LOOP
UPDATE projects
SET color = colors[1 + (color_index % array_length(colors, 1))]
WHERE id = project_record.id;
color_index := color_index + 1;
END LOOP;
END $$;
-- Убираем DEFAULT, так как теперь все проекты имеют цвет
ALTER TABLE projects
ALTER COLUMN color DROP DEFAULT;
CREATE INDEX IF NOT EXISTS idx_projects_color ON projects(color);
COMMENT ON COLUMN projects.color IS 'Project color in HEX format (e.g., #FF5733)';

View File

@@ -0,0 +1,9 @@
-- Migration: Remove position field from tasks table
-- Date: 2026-02-02
--
-- This migration removes the position field from tasks table.
DROP INDEX IF EXISTS idx_tasks_parent_position;
ALTER TABLE tasks
DROP COLUMN IF EXISTS position;

View File

@@ -0,0 +1,49 @@
-- Migration: Add position field to tasks table for subtasks ordering
-- Date: 2026-02-02
--
-- This migration adds position field to tasks table to allow
-- custom ordering of subtasks. The field is NULL for regular tasks
-- and contains position number for subtasks (tasks with parent_task_id).
-- Добавляем поле position
ALTER TABLE tasks
ADD COLUMN position INTEGER;
-- Заполняем позиции для всех существующих подзадач
-- Позиции присваиваются по порядку id в рамках каждой родительской задачи
DO $$
DECLARE
parent_record RECORD;
subtask_record RECORD;
pos INTEGER;
BEGIN
-- Для каждой родительской задачи
FOR parent_record IN
SELECT DISTINCT parent_task_id
FROM tasks
WHERE parent_task_id IS NOT NULL
ORDER BY parent_task_id
LOOP
pos := 0;
-- Обновляем подзадачи этой родительской задачи
FOR subtask_record IN
SELECT id
FROM tasks
WHERE parent_task_id = parent_record.parent_task_id
AND deleted = FALSE
ORDER BY id
LOOP
UPDATE tasks
SET position = pos
WHERE id = subtask_record.id;
pos := pos + 1;
END LOOP;
END LOOP;
END $$;
-- Создаем индекс для быстрой сортировки подзадач
CREATE INDEX idx_tasks_parent_position ON tasks(parent_task_id, position)
WHERE parent_task_id IS NOT NULL AND deleted = FALSE;
COMMENT ON COLUMN tasks.position IS 'Position of subtask within parent task. NULL for regular tasks.';

View File

@@ -0,0 +1,2 @@
DROP TABLE IF EXISTS tracking_invite_tokens;
DROP TABLE IF EXISTS user_tracking;

View File

@@ -0,0 +1,24 @@
-- Таблица отслеживания между пользователями
CREATE TABLE user_tracking (
id SERIAL PRIMARY KEY,
tracker_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
tracked_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT unique_tracking_pair UNIQUE (tracker_id, tracked_id),
CONSTRAINT no_self_tracking CHECK (tracker_id != tracked_id)
);
CREATE INDEX idx_user_tracking_tracker ON user_tracking(tracker_id);
CREATE INDEX idx_user_tracking_tracked ON user_tracking(tracked_id);
-- Таблица токенов приглашений (живут 1 час)
CREATE TABLE tracking_invite_tokens (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token VARCHAR(64) NOT NULL UNIQUE,
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_tracking_invite_tokens_token ON tracking_invite_tokens(token);
CREATE INDEX idx_tracking_invite_tokens_user ON tracking_invite_tokens(user_id);

View File

@@ -0,0 +1,36 @@
-- Migration: Remove group_name field from wishlist_items and tasks tables
-- Date: 2026-02-XX
--
-- This migration reverses the changes made in 000014_add_group_name.up.sql
-- Step 1: Drop materialized view
DROP MATERIALIZED VIEW IF EXISTS user_group_suggestions_mv;
-- Step 2: Drop indexes on group_name
DROP INDEX IF EXISTS idx_tasks_group_name;
DROP INDEX IF EXISTS idx_wishlist_items_group_name;
-- Step 3: Remove group_name from tasks
ALTER TABLE tasks
DROP COLUMN group_name;
-- Step 4: Add back project_id to wishlist_items
ALTER TABLE wishlist_items
ADD COLUMN project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL;
-- Step 5: Try to restore project_id from group_name (if possible)
-- Note: This is best-effort, as group_name might not match project names exactly
UPDATE wishlist_items wi
SET project_id = p.id
FROM projects p
WHERE wi.group_name = p.name
AND wi.group_name IS NOT NULL
AND wi.group_name != ''
AND p.deleted = FALSE;
-- Step 6: Create index on project_id
CREATE INDEX idx_wishlist_items_project_id ON wishlist_items(project_id);
-- Step 7: Remove group_name from wishlist_items
ALTER TABLE wishlist_items
DROP COLUMN group_name;

View File

@@ -0,0 +1,60 @@
-- Migration: Add group_name field to wishlist_items and tasks tables
-- Date: 2026-02-XX
--
-- This migration:
-- 1. Adds group_name field to wishlist_items (replacing project_id)
-- 2. Migrates existing data from project_id to group_name
-- 3. Removes project_id column from wishlist_items
-- 4. Adds group_name field to tasks
-- 5. Creates materialized view for group suggestions
-- Step 1: Add group_name to wishlist_items
ALTER TABLE wishlist_items
ADD COLUMN group_name VARCHAR(255);
-- Step 2: Migrate existing data from project_id to group_name
UPDATE wishlist_items wi
SET group_name = p.name
FROM projects p
WHERE wi.project_id = p.id AND wi.project_id IS NOT NULL;
-- Step 3: Remove project_id column and its index
DROP INDEX IF EXISTS idx_wishlist_items_project_id;
ALTER TABLE wishlist_items
DROP COLUMN project_id;
-- Step 4: Add group_name to tasks
ALTER TABLE tasks
ADD COLUMN group_name VARCHAR(255);
-- Step 5: Create indexes on group_name
CREATE INDEX idx_wishlist_items_group_name ON wishlist_items(group_name) WHERE group_name IS NOT NULL;
CREATE INDEX idx_tasks_group_name ON tasks(group_name) WHERE group_name IS NOT NULL;
-- Step 6: Create materialized view for group suggestions
CREATE MATERIALIZED VIEW user_group_suggestions_mv AS
SELECT DISTINCT user_id, group_name FROM (
-- Желания пользователя (собственные)
SELECT wi.user_id, wi.group_name FROM wishlist_items wi
WHERE wi.deleted = FALSE AND wi.group_name IS NOT NULL AND wi.group_name != ''
UNION
-- Желания с досок, на которых пользователь участник
SELECT wbm.user_id, wi.group_name FROM wishlist_items wi
JOIN wishlist_board_members wbm ON wi.board_id = wbm.board_id
WHERE wi.deleted = FALSE AND wi.group_name IS NOT NULL AND wi.group_name != ''
UNION
-- Задачи пользователя
SELECT t.user_id, t.group_name FROM tasks t
WHERE t.deleted = FALSE AND t.group_name IS NOT NULL AND t.group_name != ''
UNION
-- Имена проектов пользователя
SELECT p.user_id, p.name FROM projects p
WHERE p.deleted = FALSE
) sub;
-- Step 7: Create unique index for CONCURRENT refresh
CREATE UNIQUE INDEX idx_user_group_suggestions_mv_user_group ON user_group_suggestions_mv(user_id, group_name);
COMMENT ON COLUMN wishlist_items.group_name IS 'Group name for wishlist item (free text, replaces project_id)';
COMMENT ON COLUMN tasks.group_name IS 'Group name for task (free text)';
COMMENT ON MATERIALIZED VIEW user_group_suggestions_mv IS 'Materialized view for group name suggestions per user';

View File

@@ -0,0 +1,2 @@
DROP TABLE IF EXISTS fitbit_daily_stats;
DROP TABLE IF EXISTS fitbit_integrations;

View File

@@ -0,0 +1,38 @@
-- Fitbit integrations table (depends on users)
CREATE TABLE fitbit_integrations (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
fitbit_user_id VARCHAR(255),
access_token TEXT,
refresh_token TEXT,
token_expires_at TIMESTAMP WITH TIME ZONE,
goal_steps_min INTEGER DEFAULT 8000,
goal_steps_max INTEGER DEFAULT 10000,
goal_floors_min INTEGER DEFAULT 8,
goal_floors_max INTEGER DEFAULT 10,
goal_azm_min INTEGER DEFAULT 22,
goal_azm_max INTEGER DEFAULT 44,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fitbit_integrations_user_id_unique UNIQUE (user_id)
);
CREATE INDEX idx_fitbit_integrations_user_id ON fitbit_integrations(user_id);
CREATE UNIQUE INDEX idx_fitbit_integrations_fitbit_user_id ON fitbit_integrations(fitbit_user_id) WHERE fitbit_user_id IS NOT NULL;
-- Fitbit daily stats table (depends on users and fitbit_integrations)
CREATE TABLE fitbit_daily_stats (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
date DATE NOT NULL,
steps INTEGER DEFAULT 0,
floors INTEGER DEFAULT 0,
active_zone_minutes INTEGER DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fitbit_daily_stats_user_date_unique UNIQUE (user_id, date)
);
CREATE INDEX idx_fitbit_daily_stats_user_id ON fitbit_daily_stats(user_id);
CREATE INDEX idx_fitbit_daily_stats_date ON fitbit_daily_stats(date);
CREATE INDEX idx_fitbit_daily_stats_user_date ON fitbit_daily_stats(user_id, date);

View File

@@ -0,0 +1,3 @@
-- Migration: Drop project_score_sample_mv materialized view
DROP MATERIALIZED VIEW IF EXISTS project_score_sample_mv;

View File

@@ -0,0 +1,31 @@
-- Migration: Add project_score_sample_mv materialized view
--
-- One row per (project_id, score, user_id): sum of nodes.score per entry,
-- representative entry_message (latest by date). Used for admin display and reporting.
CREATE MATERIALIZED VIEW project_score_sample_mv AS
WITH entry_scores AS (
SELECT
n.project_id,
n.entry_id,
n.user_id,
SUM(n.score) AS score,
MAX(n.created_date) AS created_date
FROM nodes n
GROUP BY n.project_id, n.entry_id, n.user_id
)
SELECT DISTINCT ON (es.project_id, es.score, es.user_id)
es.project_id,
es.score,
e.text AS entry_message,
es.user_id,
es.created_date
FROM entry_scores es
JOIN entries e ON e.id = es.entry_id
ORDER BY es.project_id, es.score, es.user_id, es.created_date DESC
WITH DATA;
CREATE INDEX idx_project_score_sample_mv_project_score_user ON project_score_sample_mv(project_id, score, user_id);
CREATE INDEX idx_project_score_sample_mv_user_id ON project_score_sample_mv(user_id);
COMMENT ON MATERIALIZED VIEW project_score_sample_mv IS 'One row per (project_id, score, user_id): sum of nodes per entry, representative entry_message (latest by date).';

View File

@@ -0,0 +1,30 @@
-- Revert to previous MV definition (one row per project_id, score, user_id)
DROP MATERIALIZED VIEW IF EXISTS project_score_sample_mv;
CREATE MATERIALIZED VIEW project_score_sample_mv AS
WITH entry_scores AS (
SELECT
n.project_id,
n.entry_id,
n.user_id,
SUM(n.score) AS score,
MAX(n.created_date) AS created_date
FROM nodes n
GROUP BY n.project_id, n.entry_id, n.user_id
)
SELECT DISTINCT ON (es.project_id, es.score, es.user_id)
es.project_id,
es.score,
e.text AS entry_message,
es.user_id,
es.created_date
FROM entry_scores es
JOIN entries e ON e.id = es.entry_id
ORDER BY es.project_id, es.score, es.user_id, es.created_date DESC
WITH DATA;
CREATE INDEX idx_project_score_sample_mv_project_score_user ON project_score_sample_mv(project_id, score, user_id);
CREATE INDEX idx_project_score_sample_mv_user_id ON project_score_sample_mv(user_id);
COMMENT ON MATERIALIZED VIEW project_score_sample_mv IS 'One row per (project_id, score, user_id): sum of nodes per entry, representative entry_message (latest by date).';

View File

@@ -0,0 +1,42 @@
-- Migration: Make entry_message unique per (project_id, user_id) in project_score_sample_mv
--
-- One row per (project_id, user_id, entry_message): choose the row with latest created_date.
-- Ensures the same entry_message does not repeat for different score values.
DROP MATERIALIZED VIEW IF EXISTS project_score_sample_mv;
CREATE MATERIALIZED VIEW project_score_sample_mv AS
WITH entry_scores AS (
SELECT
n.project_id,
n.entry_id,
n.user_id,
SUM(n.score) AS score,
MAX(n.created_date) AS created_date
FROM nodes n
GROUP BY n.project_id, n.entry_id, n.user_id
),
with_message AS (
SELECT
es.project_id,
es.score,
e.text AS entry_message,
es.user_id,
es.created_date
FROM entry_scores es
JOIN entries e ON e.id = es.entry_id
)
SELECT DISTINCT ON (project_id, user_id, entry_message)
project_id,
score,
entry_message,
user_id,
created_date
FROM with_message
ORDER BY project_id, user_id, entry_message, created_date DESC
WITH DATA;
CREATE INDEX idx_project_score_sample_mv_project_score_user ON project_score_sample_mv(project_id, score, user_id);
CREATE INDEX idx_project_score_sample_mv_user_id ON project_score_sample_mv(user_id);
COMMENT ON MATERIALIZED VIEW project_score_sample_mv IS 'One row per (project_id, user_id, entry_message): representative row (latest by date). entry_message is unique per project and user.';

View File

@@ -0,0 +1,39 @@
-- Revert to one row per (project_id, user_id, entry_message)
DROP MATERIALIZED VIEW IF EXISTS project_score_sample_mv;
CREATE MATERIALIZED VIEW project_score_sample_mv AS
WITH entry_scores AS (
SELECT
n.project_id,
n.entry_id,
n.user_id,
SUM(n.score) AS score,
MAX(n.created_date) AS created_date
FROM nodes n
GROUP BY n.project_id, n.entry_id, n.user_id
),
with_message AS (
SELECT
es.project_id,
es.score,
e.text AS entry_message,
es.user_id,
es.created_date
FROM entry_scores es
JOIN entries e ON e.id = es.entry_id
)
SELECT DISTINCT ON (project_id, user_id, entry_message)
project_id,
score,
entry_message,
user_id,
created_date
FROM with_message
ORDER BY project_id, user_id, entry_message, created_date DESC
WITH DATA;
CREATE INDEX idx_project_score_sample_mv_project_score_user ON project_score_sample_mv(project_id, score, user_id);
CREATE INDEX idx_project_score_sample_mv_user_id ON project_score_sample_mv(user_id);
COMMENT ON MATERIALIZED VIEW project_score_sample_mv IS 'One row per (project_id, user_id, entry_message): representative row (latest by date).';

View File

@@ -0,0 +1,32 @@
-- Migration: One row per (project_id, user_id, score) in project_score_sample_mv
--
-- For each score value (per project and user) exactly one record; representative entry_message (latest by date).
DROP MATERIALIZED VIEW IF EXISTS project_score_sample_mv;
CREATE MATERIALIZED VIEW project_score_sample_mv AS
WITH entry_scores AS (
SELECT
n.project_id,
n.entry_id,
n.user_id,
SUM(n.score) AS score,
MAX(n.created_date) AS created_date
FROM nodes n
GROUP BY n.project_id, n.entry_id, n.user_id
)
SELECT DISTINCT ON (es.project_id, es.score, es.user_id)
es.project_id,
es.score,
e.text AS entry_message,
es.user_id,
es.created_date
FROM entry_scores es
JOIN entries e ON e.id = es.entry_id
ORDER BY es.project_id, es.score, es.user_id, es.created_date DESC
WITH DATA;
CREATE INDEX idx_project_score_sample_mv_project_score_user ON project_score_sample_mv(project_id, score, user_id);
CREATE INDEX idx_project_score_sample_mv_user_id ON project_score_sample_mv(user_id);
COMMENT ON MATERIALIZED VIEW project_score_sample_mv IS 'One row per (project_id, score, user_id): one record per score, representative entry_message (latest by date).';

View File

@@ -0,0 +1,30 @@
-- Revert to one row per (project_id, score, user_id)
DROP MATERIALIZED VIEW IF EXISTS project_score_sample_mv;
CREATE MATERIALIZED VIEW project_score_sample_mv AS
WITH entry_scores AS (
SELECT
n.project_id,
n.entry_id,
n.user_id,
SUM(n.score) AS score,
MAX(n.created_date) AS created_date
FROM nodes n
GROUP BY n.project_id, n.entry_id, n.user_id
)
SELECT DISTINCT ON (es.project_id, es.score, es.user_id)
es.project_id,
es.score,
e.text AS entry_message,
es.user_id,
es.created_date
FROM entry_scores es
JOIN entries e ON e.id = es.entry_id
ORDER BY es.project_id, es.score, es.user_id, es.created_date DESC
WITH DATA;
CREATE INDEX idx_project_score_sample_mv_project_score_user ON project_score_sample_mv(project_id, score, user_id);
CREATE INDEX idx_project_score_sample_mv_user_id ON project_score_sample_mv(user_id);
COMMENT ON MATERIALIZED VIEW project_score_sample_mv IS 'One row per (project_id, score, user_id): one record per score, representative entry_message (latest by date).';

View File

@@ -0,0 +1,42 @@
-- Migration: One entry_message per (project_id, user_id) in project_score_sample_mv
--
-- One record per score (per project, user) and one record per entry_message per project.
-- DISTINCT ON (project_id, user_id, entry_message): same message with different scores → one row (latest by date).
DROP MATERIALIZED VIEW IF EXISTS project_score_sample_mv;
CREATE MATERIALIZED VIEW project_score_sample_mv AS
WITH entry_scores AS (
SELECT
n.project_id,
n.entry_id,
n.user_id,
SUM(n.score) AS score,
MAX(n.created_date) AS created_date
FROM nodes n
GROUP BY n.project_id, n.entry_id, n.user_id
),
with_message AS (
SELECT
es.project_id,
es.score,
e.text AS entry_message,
es.user_id,
es.created_date
FROM entry_scores es
JOIN entries e ON e.id = es.entry_id
)
SELECT DISTINCT ON (project_id, user_id, entry_message)
project_id,
score,
entry_message,
user_id,
created_date
FROM with_message
ORDER BY project_id, user_id, entry_message, created_date DESC
WITH DATA;
CREATE INDEX idx_project_score_sample_mv_project_score_user ON project_score_sample_mv(project_id, score, user_id);
CREATE INDEX idx_project_score_sample_mv_user_id ON project_score_sample_mv(user_id);
COMMENT ON MATERIALIZED VIEW project_score_sample_mv IS 'One row per (project_id, user_id, entry_message): one record per score (chosen row), one entry_message per project; representative = latest by date.';

View File

@@ -0,0 +1,51 @@
-- Migration: Restore max_score column and MV using max_score for normalized_total_score
ALTER TABLE weekly_goals ADD COLUMN max_score NUMERIC(10,4);
UPDATE weekly_goals SET max_score = max_goal_score WHERE max_score IS NULL;
DROP MATERIALIZED VIEW IF EXISTS weekly_report_mv;
CREATE MATERIALIZED VIEW weekly_report_mv AS
SELECT
p.id AS project_id,
agg.report_year,
agg.report_week,
COALESCE(agg.total_score, 0.0000) AS total_score,
CASE
WHEN wg.max_score IS NULL THEN COALESCE(agg.total_score, 0.0000)
ELSE LEAST(COALESCE(agg.total_score, 0.0000), wg.max_score)
END AS normalized_total_score
FROM
projects p
LEFT JOIN
(
SELECT
n.project_id,
EXTRACT(ISOYEAR FROM n.created_date)::INTEGER AS report_year,
EXTRACT(WEEK FROM n.created_date)::INTEGER AS report_week,
SUM(n.score) AS total_score
FROM
nodes n
WHERE
(EXTRACT(ISOYEAR FROM n.created_date)::INTEGER < EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER)
OR (EXTRACT(ISOYEAR FROM n.created_date)::INTEGER = EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER
AND EXTRACT(WEEK FROM n.created_date)::INTEGER < EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER)
GROUP BY
1, 2, 3
) agg
ON p.id = agg.project_id
LEFT JOIN
weekly_goals wg
ON wg.project_id = p.id
AND wg.goal_year = agg.report_year
AND wg.goal_week = agg.report_week
WHERE
p.deleted = FALSE
ORDER BY
p.id, agg.report_year, agg.report_week
WITH DATA;
CREATE INDEX idx_weekly_report_mv_project_year_week
ON weekly_report_mv(project_id, report_year, report_week);
COMMENT ON MATERIALIZED VIEW weekly_report_mv IS 'Materialized view aggregating weekly scores by project using ISOYEAR for correct week calculations at year boundaries. Includes all projects via LEFT JOIN. Adds normalized_total_score using weekly_goals.max_score snapshot. Contains only historical data (excludes current week). Uses nodes.created_date (denormalized) instead of entries.created_date.';

View File

@@ -0,0 +1,51 @@
-- Migration: Remove max_score from weekly_goals, use max_goal_score for normalized_total_score
-- normalized_total_score is now computed from max_goal_score (current goal) instead of max_score (snapshot).
DROP MATERIALIZED VIEW IF EXISTS weekly_report_mv;
CREATE MATERIALIZED VIEW weekly_report_mv AS
SELECT
p.id AS project_id,
agg.report_year,
agg.report_week,
COALESCE(agg.total_score, 0.0000) AS total_score,
CASE
WHEN wg.max_goal_score IS NULL THEN COALESCE(agg.total_score, 0.0000)
ELSE LEAST(COALESCE(agg.total_score, 0.0000), wg.max_goal_score)
END AS normalized_total_score
FROM
projects p
LEFT JOIN
(
SELECT
n.project_id,
EXTRACT(ISOYEAR FROM n.created_date)::INTEGER AS report_year,
EXTRACT(WEEK FROM n.created_date)::INTEGER AS report_week,
SUM(n.score) AS total_score
FROM
nodes n
WHERE
(EXTRACT(ISOYEAR FROM n.created_date)::INTEGER < EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER)
OR (EXTRACT(ISOYEAR FROM n.created_date)::INTEGER = EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER
AND EXTRACT(WEEK FROM n.created_date)::INTEGER < EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER)
GROUP BY
1, 2, 3
) agg
ON p.id = agg.project_id
LEFT JOIN
weekly_goals wg
ON wg.project_id = p.id
AND wg.goal_year = agg.report_year
AND wg.goal_week = agg.report_week
WHERE
p.deleted = FALSE
ORDER BY
p.id, agg.report_year, agg.report_week
WITH DATA;
CREATE INDEX idx_weekly_report_mv_project_year_week
ON weekly_report_mv(project_id, report_year, report_week);
COMMENT ON MATERIALIZED VIEW weekly_report_mv IS 'Materialized view aggregating weekly scores by project using ISOYEAR for correct week calculations at year boundaries. Includes all projects via LEFT JOIN. Adds normalized_total_score using weekly_goals.max_goal_score. Contains only historical data (excludes current week). Uses nodes.created_date (denormalized) instead of entries.created_date.';
ALTER TABLE weekly_goals DROP COLUMN max_score;

View File

@@ -0,0 +1,20 @@
-- Откат: удаляем новые колонки
ALTER TABLE fitbit_integrations DROP COLUMN IF EXISTS steps_task_id;
ALTER TABLE fitbit_integrations DROP COLUMN IF EXISTS floors_task_id;
ALTER TABLE fitbit_integrations DROP COLUMN IF EXISTS steps_goal_task_id;
ALTER TABLE fitbit_integrations DROP COLUMN IF EXISTS steps_goal_subtask_id;
ALTER TABLE fitbit_integrations DROP COLUMN IF EXISTS floors_goal_task_id;
ALTER TABLE fitbit_integrations DROP COLUMN IF EXISTS floors_goal_subtask_id;
ALTER TABLE fitbit_daily_stats DROP COLUMN IF EXISTS goal_steps;
ALTER TABLE fitbit_daily_stats DROP COLUMN IF EXISTS goal_floors;
-- Восстанавливаем старые колонки
ALTER TABLE fitbit_daily_stats ADD COLUMN active_zone_minutes INTEGER DEFAULT 0;
ALTER TABLE fitbit_integrations ADD COLUMN goal_steps_min INTEGER DEFAULT 8000;
ALTER TABLE fitbit_integrations ADD COLUMN goal_steps_max INTEGER DEFAULT 10000;
ALTER TABLE fitbit_integrations ADD COLUMN goal_floors_min INTEGER DEFAULT 8;
ALTER TABLE fitbit_integrations ADD COLUMN goal_floors_max INTEGER DEFAULT 10;
ALTER TABLE fitbit_integrations ADD COLUMN goal_azm_min INTEGER DEFAULT 22;
ALTER TABLE fitbit_integrations ADD COLUMN goal_azm_max INTEGER DEFAULT 44;

View File

@@ -0,0 +1,42 @@
-- =============================================
-- Удаляем старые колонки целей (goals) из fitbit_integrations
-- Теперь цели берутся из Fitbit API
-- =============================================
ALTER TABLE fitbit_integrations DROP COLUMN IF EXISTS goal_steps_min;
ALTER TABLE fitbit_integrations DROP COLUMN IF EXISTS goal_steps_max;
ALTER TABLE fitbit_integrations DROP COLUMN IF EXISTS goal_floors_min;
ALTER TABLE fitbit_integrations DROP COLUMN IF EXISTS goal_floors_max;
ALTER TABLE fitbit_integrations DROP COLUMN IF EXISTS goal_azm_min;
ALTER TABLE fitbit_integrations DROP COLUMN IF EXISTS goal_azm_max;
-- =============================================
-- Удаляем AZM колонку из fitbit_daily_stats
-- =============================================
ALTER TABLE fitbit_daily_stats DROP COLUMN IF EXISTS active_zone_minutes;
-- =============================================
-- Добавляем колонки для кэширования целей из Fitbit API
-- =============================================
ALTER TABLE fitbit_daily_stats ADD COLUMN goal_steps INTEGER;
ALTER TABLE fitbit_daily_stats ADD COLUMN goal_floors INTEGER;
-- =============================================
-- Добавляем привязки к задачам для записи прогресса
-- steps_task_id - задача куда записывать шаги как progression_value
-- floors_task_id - задача куда записывать этажи как progression_value
-- =============================================
ALTER TABLE fitbit_integrations ADD COLUMN steps_task_id INTEGER REFERENCES tasks(id) ON DELETE SET NULL;
ALTER TABLE fitbit_integrations ADD COLUMN floors_task_id INTEGER REFERENCES tasks(id) ON DELETE SET NULL;
-- =============================================
-- Добавляем привязки для целей (goals)
-- Для каждой цели храним И задачу И подзадачу
-- steps_goal_task_id - родительская задача для цели шагов
-- steps_goal_subtask_id - подзадача внутри неё, которая будет checked/unchecked
-- floors_goal_task_id - родительская задача для цели этажей
-- floors_goal_subtask_id - подзадача внутри неё
-- =============================================
ALTER TABLE fitbit_integrations ADD COLUMN steps_goal_task_id INTEGER REFERENCES tasks(id) ON DELETE SET NULL;
ALTER TABLE fitbit_integrations ADD COLUMN steps_goal_subtask_id INTEGER REFERENCES tasks(id) ON DELETE SET NULL;
ALTER TABLE fitbit_integrations ADD COLUMN floors_goal_task_id INTEGER REFERENCES tasks(id) ON DELETE SET NULL;
ALTER TABLE fitbit_integrations ADD COLUMN floors_goal_subtask_id INTEGER REFERENCES tasks(id) ON DELETE SET NULL;

View File

@@ -0,0 +1,4 @@
-- Restore max_score for rollback (snapshot of goal; can be repopulated from max_goal_score)
ALTER TABLE weekly_goals ADD COLUMN IF NOT EXISTS max_score NUMERIC(10,4);
UPDATE weekly_goals SET max_score = max_goal_score WHERE max_score IS NULL;

View File

@@ -0,0 +1,4 @@
-- Migration: Drop weekly_goals.max_score if still present (e.g. prod where 000020 wasn't applied)
-- normalized_total_score in weekly_report_mv uses max_goal_score; max_score is unused.
ALTER TABLE weekly_goals DROP COLUMN IF EXISTS max_score;

View File

@@ -0,0 +1,81 @@
-- Migration: Rollback to MV using max_score and restore max_score column.
DROP MATERIALIZED VIEW IF EXISTS projects_median_mv;
ALTER TABLE weekly_goals ADD COLUMN IF NOT EXISTS max_score NUMERIC(10,4);
UPDATE weekly_goals SET max_score = max_goal_score WHERE max_score IS NULL;
DROP MATERIALIZED VIEW IF EXISTS weekly_report_mv;
CREATE MATERIALIZED VIEW weekly_report_mv AS
SELECT
p.id AS project_id,
agg.report_year,
agg.report_week,
COALESCE(agg.total_score, 0.0000) AS total_score,
CASE
WHEN wg.max_score IS NULL THEN COALESCE(agg.total_score, 0.0000)
ELSE LEAST(COALESCE(agg.total_score, 0.0000), wg.max_score)
END AS normalized_total_score
FROM
projects p
LEFT JOIN
(
SELECT
n.project_id,
EXTRACT(ISOYEAR FROM n.created_date)::INTEGER AS report_year,
EXTRACT(WEEK FROM n.created_date)::INTEGER AS report_week,
SUM(n.score) AS total_score
FROM
nodes n
WHERE
(EXTRACT(ISOYEAR FROM n.created_date)::INTEGER < EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER)
OR (EXTRACT(ISOYEAR FROM n.created_date)::INTEGER = EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER
AND EXTRACT(WEEK FROM n.created_date)::INTEGER < EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER)
GROUP BY
1, 2, 3
) agg
ON p.id = agg.project_id
LEFT JOIN
weekly_goals wg
ON wg.project_id = p.id
AND wg.goal_year = agg.report_year
AND wg.goal_week = agg.report_week
WHERE
p.deleted = FALSE
ORDER BY
p.id, agg.report_year, agg.report_week
WITH DATA;
CREATE INDEX idx_weekly_report_mv_project_year_week
ON weekly_report_mv(project_id, report_year, report_week);
COMMENT ON MATERIALIZED VIEW weekly_report_mv IS 'Materialized view aggregating weekly scores by project using ISOYEAR for correct week calculations at year boundaries. Includes all projects via LEFT JOIN. Adds normalized_total_score using weekly_goals.max_score snapshot. Contains only historical data (excludes current week). Uses nodes.created_date (denormalized) instead of entries.created_date.';
-- Recreate projects_median_mv (last 4 weeks per 000008)
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 <= 4 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 4 weeks of historical data. Includes user_id for multi-tenant support.';

View File

@@ -0,0 +1,82 @@
-- Migration: Fix weekly_report_mv to use max_goal_score for normalized_total_score.
-- Safe to run on DBs where 000020 was not applied (MV still uses max_score, column exists but is NULL).
-- projects_median_mv depends on weekly_report_mv, so we drop and recreate it.
DROP MATERIALIZED VIEW IF EXISTS projects_median_mv;
DROP MATERIALIZED VIEW IF EXISTS weekly_report_mv;
CREATE MATERIALIZED VIEW weekly_report_mv AS
SELECT
p.id AS project_id,
agg.report_year,
agg.report_week,
COALESCE(agg.total_score, 0.0000) AS total_score,
CASE
WHEN wg.max_goal_score IS NULL THEN COALESCE(agg.total_score, 0.0000)
ELSE LEAST(COALESCE(agg.total_score, 0.0000), wg.max_goal_score)
END AS normalized_total_score
FROM
projects p
LEFT JOIN
(
SELECT
n.project_id,
EXTRACT(ISOYEAR FROM n.created_date)::INTEGER AS report_year,
EXTRACT(WEEK FROM n.created_date)::INTEGER AS report_week,
SUM(n.score) AS total_score
FROM
nodes n
WHERE
(EXTRACT(ISOYEAR FROM n.created_date)::INTEGER < EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER)
OR (EXTRACT(ISOYEAR FROM n.created_date)::INTEGER = EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER
AND EXTRACT(WEEK FROM n.created_date)::INTEGER < EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER)
GROUP BY
1, 2, 3
) agg
ON p.id = agg.project_id
LEFT JOIN
weekly_goals wg
ON wg.project_id = p.id
AND wg.goal_year = agg.report_year
AND wg.goal_week = agg.report_week
WHERE
p.deleted = FALSE
ORDER BY
p.id, agg.report_year, agg.report_week
WITH DATA;
CREATE INDEX idx_weekly_report_mv_project_year_week
ON weekly_report_mv(project_id, report_year, report_week);
COMMENT ON MATERIALIZED VIEW weekly_report_mv IS 'Materialized view aggregating weekly scores by project using ISOYEAR for correct week calculations at year boundaries. Includes all projects via LEFT JOIN. Adds normalized_total_score using weekly_goals.max_goal_score. Contains only historical data (excludes current week). Uses nodes.created_date (denormalized) instead of entries.created_date.';
-- Recreate projects_median_mv (depends on weekly_report_mv, last 4 weeks per 000008)
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 <= 4 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 4 weeks of historical data. Includes user_id for multi-tenant support.';
ALTER TABLE weekly_goals DROP COLUMN IF EXISTS max_score;

View File

@@ -0,0 +1,30 @@
-- Migration: Recreate projects_median_mv (rollback of 000024)
-- Definition: last 4 weeks per 000008/000023
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 <= 4 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 4 weeks of historical data. Includes user_id for multi-tenant support.';

View File

@@ -0,0 +1,4 @@
-- Migration: Drop projects_median_mv (unlock weeks now use weekly_goals.min_goal_score)
-- Date: 2026-02-24
DROP MATERIALIZED VIEW IF EXISTS projects_median_mv;

View File

@@ -0,0 +1,3 @@
-- Remove rejected column from wishlist_items
DROP INDEX IF EXISTS idx_wishlist_items_rejected;
ALTER TABLE wishlist_items DROP COLUMN IF EXISTS rejected;

View File

@@ -0,0 +1,5 @@
-- Add rejected column to wishlist_items
ALTER TABLE wishlist_items ADD COLUMN rejected BOOLEAN DEFAULT FALSE;
-- Create index for filtering by rejected status
CREATE INDEX idx_wishlist_items_rejected ON wishlist_items(rejected) WHERE rejected = TRUE;

View File

@@ -0,0 +1,3 @@
DROP TABLE IF EXISTS shopping_items;
DROP TABLE IF EXISTS shopping_board_members;
DROP TABLE IF EXISTS shopping_boards;

View File

@@ -0,0 +1,50 @@
-- Shopping boards (аналог wishlist_boards)
CREATE TABLE shopping_boards (
id SERIAL PRIMARY KEY,
owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
invite_token VARCHAR(64) UNIQUE,
invite_enabled BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
deleted BOOLEAN DEFAULT FALSE
);
CREATE INDEX idx_shopping_boards_owner_id ON shopping_boards(owner_id);
CREATE INDEX idx_shopping_boards_invite_token ON shopping_boards(invite_token) WHERE invite_token IS NOT NULL;
CREATE INDEX idx_shopping_boards_owner_deleted ON shopping_boards(owner_id, deleted);
-- Shopping board members (аналог wishlist_board_members)
CREATE TABLE shopping_board_members (
id SERIAL PRIMARY KEY,
board_id INTEGER NOT NULL REFERENCES shopping_boards(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
joined_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT unique_shopping_board_member UNIQUE (board_id, user_id)
);
CREATE INDEX idx_shopping_board_members_board_id ON shopping_board_members(board_id);
CREATE INDEX idx_shopping_board_members_user_id ON shopping_board_members(user_id);
-- Shopping items (товары)
CREATE TABLE shopping_items (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
board_id INTEGER NOT NULL REFERENCES shopping_boards(id) ON DELETE CASCADE,
author_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
group_name VARCHAR(255),
volume_base NUMERIC(10,4) NOT NULL DEFAULT 1,
repetition_period INTERVAL,
next_show_at TIMESTAMP WITH TIME ZONE,
completed INTEGER DEFAULT 0,
last_completed_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
deleted BOOLEAN DEFAULT FALSE
);
CREATE INDEX idx_shopping_items_board_id ON shopping_items(board_id);
CREATE INDEX idx_shopping_items_user_id ON shopping_items(user_id);
CREATE INDEX idx_shopping_items_deleted ON shopping_items(deleted);
CREATE INDEX idx_shopping_items_next_show_at ON shopping_items(next_show_at);

View File

@@ -0,0 +1 @@
ALTER TABLE shopping_items DROP COLUMN description;

View File

@@ -0,0 +1 @@
ALTER TABLE shopping_items ADD COLUMN description TEXT;

View File

@@ -0,0 +1 @@
DROP TABLE IF EXISTS shopping_item_history;

View File

@@ -0,0 +1,10 @@
CREATE TABLE shopping_item_history (
id SERIAL PRIMARY KEY,
item_id INTEGER NOT NULL REFERENCES shopping_items(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id),
name VARCHAR(255) NOT NULL,
volume NUMERIC(10,4) NOT NULL DEFAULT 1,
completed_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_shopping_item_history_item_id ON shopping_item_history(item_id);

View File

@@ -0,0 +1,3 @@
ALTER TABLE tasks DROP COLUMN IF EXISTS purchase_config_id;
DROP TABLE IF EXISTS purchase_config_boards;
DROP TABLE IF EXISTS purchase_configs;

View File

@@ -0,0 +1,24 @@
-- Purchase task configurations
CREATE TABLE purchase_configs (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_purchase_configs_user_id ON purchase_configs(user_id);
-- Purchase config board/group associations
CREATE TABLE purchase_config_boards (
id SERIAL PRIMARY KEY,
purchase_config_id INTEGER NOT NULL REFERENCES purchase_configs(id) ON DELETE CASCADE,
board_id INTEGER NOT NULL REFERENCES shopping_boards(id) ON DELETE CASCADE,
group_name VARCHAR(255),
UNIQUE (purchase_config_id, board_id, group_name)
);
CREATE INDEX idx_purchase_config_boards_config_id ON purchase_config_boards(purchase_config_id);
CREATE INDEX idx_purchase_config_boards_board_id ON purchase_config_boards(board_id);
-- Add purchase_config_id to tasks
ALTER TABLE tasks ADD COLUMN purchase_config_id INTEGER REFERENCES purchase_configs(id) ON DELETE SET NULL;
CREATE INDEX idx_tasks_purchase_config_id ON tasks(purchase_config_id);

View File

@@ -0,0 +1,3 @@
ALTER TABLE users
DROP COLUMN IF EXISTS priorities_confirmed_year,
DROP COLUMN IF EXISTS priorities_confirmed_week;

View File

@@ -0,0 +1,3 @@
ALTER TABLE users
ADD COLUMN priorities_confirmed_year INTEGER NOT NULL DEFAULT 0,
ADD COLUMN priorities_confirmed_week INTEGER NOT NULL DEFAULT 0;

View File

@@ -0,0 +1 @@
DROP TABLE IF EXISTS shopping_volume_records;

View File

@@ -0,0 +1,19 @@
-- Отдельная таблица записей об остатках (создаётся при каждом выполнении и переносе)
CREATE TABLE shopping_volume_records (
id SERIAL PRIMARY KEY,
item_id INTEGER NOT NULL REFERENCES shopping_items(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id),
action_type VARCHAR(20) NOT NULL,
volume_remaining NUMERIC(10,4),
volume_purchased NUMERIC(10,4),
daily_consumption NUMERIC(10,4),
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_shopping_volume_records_item_id ON shopping_volume_records(item_id);
-- Создаём начальные записи для всех существующих товаров (остаток 0, дата = created_at)
INSERT INTO shopping_volume_records (item_id, user_id, action_type, volume_remaining, volume_purchased, created_at)
SELECT id, user_id, 'create', 0, 0, created_at
FROM shopping_items
WHERE deleted = FALSE;

View File

@@ -0,0 +1 @@
ALTER TABLE shopping_volume_records DROP COLUMN IF EXISTS next_show_at;

View File

@@ -0,0 +1 @@
ALTER TABLE shopping_volume_records ADD COLUMN next_show_at TIMESTAMP;

View File

@@ -0,0 +1 @@
DROP TABLE IF EXISTS board_archives;

View File

@@ -0,0 +1,10 @@
CREATE TABLE board_archives (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id),
board_type VARCHAR(20) NOT NULL, -- 'wishlist' or 'shopping'
board_id INTEGER NOT NULL,
archived_at TIMESTAMP NOT NULL DEFAULT NOW(),
UNIQUE(user_id, board_type, board_id)
);
CREATE INDEX idx_board_archives_user_type ON board_archives(user_id, board_type);

View File

@@ -0,0 +1,2 @@
ALTER TABLE tasks DROP COLUMN default_auto_complete;
ALTER TABLE tasks DROP COLUMN default_progress;

View File

@@ -0,0 +1,4 @@
ALTER TABLE tasks ADD COLUMN default_auto_complete BOOLEAN DEFAULT FALSE;
ALTER TABLE tasks ADD COLUMN default_progress NUMERIC(10,4);
-- Для существующих задач: default_progress = progression_base
UPDATE tasks SET default_progress = progression_base WHERE progression_base IS NOT NULL;

View File

@@ -45,7 +45,6 @@ docker-compose exec db psql -U playeng -d playeng -f /migrations/001_create_sche
- `goal_week` (INTEGER NOT NULL)
- `min_goal_score` (NUMERIC(10,4) NOT NULL, DEFAULT 0)
- `max_goal_score` (NUMERIC(10,4))
- `max_score` (NUMERIC(10,4), NULL) — snapshot max на неделю (заполняется только для новых недель)
- `priority` (SMALLINT)
- UNIQUE CONSTRAINT: `(project_id, goal_year, goal_week)`
@@ -56,7 +55,7 @@ docker-compose exec db psql -U playeng -d playeng -f /migrations/001_create_sche
- `report_year` (INTEGER)
- `report_week` (INTEGER)
- `total_score` (NUMERIC)
- `normalized_total_score` (NUMERIC)
- `normalized_total_score` (NUMERIC) — ограничение total_score по `max_goal_score` (миграция 000020 удалила колонку `max_score`, normalized считается по `max_goal_score`)
## Миграции

Binary file not shown.

12
play-life-llm/.gitignore vendored Normal file
View File

@@ -0,0 +1,12 @@
# Env with secrets (Tavily API key, etc.)
.env
# Binary
play-life-llm
*.exe
# IDE / OS
.idea/
.vscode/
*.swp
.DS_Store

19
play-life-llm/Dockerfile Normal file
View File

@@ -0,0 +1,19 @@
# Build stage
FROM golang:1.24-alpine AS builder
WORKDIR /app
ENV GOPROXY=https://proxy.golang.org,direct
ENV GOSUMDB=sum.golang.org
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o play-life-llm .
# Runtime stage
FROM alpine:latest
RUN apk --no-cache add ca-certificates wget
WORKDIR /app
COPY --from=builder /app/play-life-llm .
EXPOSE 8090
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD wget -q -O- http://localhost:8090/health || exit 1
CMD ["./play-life-llm"]

12
play-life-llm/env.example Normal file
View File

@@ -0,0 +1,12 @@
# Ollama API base URL (default: http://localhost:11434)
# For Docker on Mac/Windows use: http://host.docker.internal:11434
OLLAMA_HOST=http://localhost:11434
# Tavily API key for web search (required when model uses web_search tool)
TAVILY_API_KEY=
# HTTP server port (default: 8090)
PORT=8090
# Default Ollama model (default: llama3.1:70b)
OLLAMA_MODEL=llama3.1:70b

5
play-life-llm/go.mod Normal file
View File

@@ -0,0 +1,5 @@
module play-life-llm
go 1.24.0
require github.com/gorilla/mux v1.8.1

2
play-life-llm/go.sum Normal file
View File

@@ -0,0 +1,2 @@
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=

View File

@@ -0,0 +1,177 @@
package handler
import (
"encoding/json"
"log"
"net/http"
"play-life-llm/internal/ollama"
"play-life-llm/internal/tavily"
)
// AskRequest is the POST /ask body.
type AskRequest struct {
Prompt string `json:"prompt"`
ResponseSchema interface{} `json:"response_schema"`
Model string `json:"model,omitempty"`
// AllowWebSearch: если true, в запрос к Ollama добавляются tools (web_search), и при вызове модели выполняется поиск через Tavily. Если false (по умолчанию), tools не передаются — модель просто возвращает JSON по схеме (подходит для простых запросов без интернета).
AllowWebSearch bool `json:"allow_web_search,omitempty"`
}
// AskResponse is the successful response (result is JSON by schema).
type AskResponse struct {
Result json.RawMessage `json:"result"`
}
// AskHandler handles POST /ask: prompt + response_schema -> LLM with optional web search, returns JSON.
type AskHandler struct {
Ollama *ollama.Client
Tavily *tavily.Client
DefaultModel string
}
// ServeHTTP implements http.Handler.
func (h *AskHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var req AskRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
sendError(w, "invalid JSON body", http.StatusBadRequest)
return
}
if req.Prompt == "" {
sendError(w, "prompt is required", http.StatusBadRequest)
return
}
if req.ResponseSchema == nil {
sendError(w, "response_schema is required", http.StatusBadRequest)
return
}
model := req.Model
if model == "" {
model = h.DefaultModel
}
if model == "" {
model = "llama3.1:70b"
}
var tools []ollama.Tool
if req.AllowWebSearch {
tools = []ollama.Tool{ollama.WebSearchTool()}
}
messages := []ollama.ChatMessage{}
if req.AllowWebSearch {
messages = append(messages, ollama.ChatMessage{
Role: "system",
Content: "When the user asks for current, recent, or real-time information (weather, prices, news, etc.), you MUST call the web_search tool with a suitable query. Do not answer from memory — use the tool and then summarize the results in your response.",
})
// Гарантированный запрос в Tavily: предпоиск по промпту пользователя, результат подмешивается в контекст.
searchQuery := req.Prompt
if len(searchQuery) > 200 {
searchQuery = searchQuery[:200]
}
log.Printf("tavily pre-search: query=%q", searchQuery)
preSearchResult, err := h.Tavily.Search(searchQuery)
if err != nil {
log.Printf("tavily pre-search error: %v", err)
preSearchResult = "search failed: " + err.Error()
} else {
log.Printf("tavily pre-search ok: %d bytes", len(preSearchResult))
}
messages = append(messages, ollama.ChatMessage{
Role: "system",
Content: "Relevant web search result for the user's question (use this to answer; if not enough, you may call web_search again):\n\n" + preSearchResult,
})
}
messages = append(messages, ollama.ChatMessage{
Role: "user", Content: req.Prompt,
})
const maxToolRounds = 20
for round := 0; round < maxToolRounds; round++ {
chatReq := &ollama.ChatRequest{
Model: model,
Messages: messages,
Stream: false,
Format: req.ResponseSchema,
Tools: tools,
}
resp, err := h.Ollama.Chat(chatReq)
if err != nil {
log.Printf("ollama chat error: %v", err)
sendError(w, "ollama request failed: "+err.Error(), http.StatusBadGateway)
return
}
messages = append(messages, resp.Message)
if n := len(resp.Message.ToolCalls); n > 0 {
log.Printf("ollama returned %d tool_calls", n)
}
if len(resp.Message.ToolCalls) == 0 {
// Final answer: message.content is JSON by schema
content := resp.Message.Content
if content == "" {
sendError(w, "empty response from model", http.StatusBadGateway)
return
}
// Return as { "result": <parsed JSON> } so client gets valid JSON
var raw json.RawMessage
if err := json.Unmarshal([]byte(content), &raw); err != nil {
// If not valid JSON, return as string inside result
raw = json.RawMessage(`"` + escapeJSONString(content) + `"`)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(AskResponse{Result: raw})
return
}
// Execute tool calls (web_search via Tavily)
for _, tc := range resp.Message.ToolCalls {
if tc.Function.Name != "web_search" {
messages = append(messages, ollama.ChatMessage{
Role: "tool", ToolName: tc.Function.Name, Content: "unknown tool",
})
continue
}
query := ollama.QueryFromToolCall(tc)
if query == "" {
// Некоторые модели подставляют в arguments не "query", а другие поля — используем промпт пользователя как поисковый запрос
query = req.Prompt
if len(query) > 200 {
query = query[:200]
}
log.Printf("web_search: query empty in tool_call, using user prompt (first 200 chars)")
}
log.Printf("tavily search: query=%q", query)
searchResult, err := h.Tavily.Search(query)
if err != nil {
log.Printf("tavily search error: %v", err)
searchResult = "search failed: " + err.Error()
} else {
log.Printf("tavily search ok: %d bytes", len(searchResult))
}
messages = append(messages, ollama.ChatMessage{
Role: "tool", ToolName: "web_search", Content: searchResult,
})
}
}
// Too many tool rounds
sendError(w, "too many tool-call rounds", http.StatusBadGateway)
}
func sendError(w http.ResponseWriter, msg string, code int) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(map[string]string{"error": msg})
}
func escapeJSONString(s string) string {
b, _ := json.Marshal(s)
return string(b[1 : len(b)-1])
}

View File

@@ -0,0 +1,17 @@
package handler
import (
"encoding/json"
"net/http"
)
// Health returns 200 with {"status": "ok"} for Docker healthcheck.
func Health(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}

View File

@@ -0,0 +1,148 @@
package ollama
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
const defaultTimeout = 10 * time.Minute
// Client calls Ollama /api/chat.
type Client struct {
BaseURL string
HTTPClient *http.Client
}
// NewClient creates an Ollama client. baseURL is e.g. "http://localhost:11434".
func NewClient(baseURL string) *Client {
return &Client{
BaseURL: baseURL,
HTTPClient: &http.Client{
Timeout: defaultTimeout,
},
}
}
// ChatRequest matches Ollama POST /api/chat body.
type ChatRequest struct {
Model string `json:"model"`
Messages []ChatMessage `json:"messages"`
Stream bool `json:"stream"`
Format interface{} `json:"format,omitempty"` // "json" or JSON schema object
Tools []Tool `json:"tools,omitempty"`
}
// ChatMessage is one message in the conversation.
type ChatMessage struct {
Role string `json:"role"` // "user", "assistant", "system", "tool"
Content string `json:"content,omitempty"`
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
ToolName string `json:"tool_name,omitempty"` // for role "tool"
}
// Tool defines a function the model may call.
type Tool struct {
Type string `json:"type"`
Function ToolFunc `json:"function"`
}
// ToolFunc describes the function.
type ToolFunc struct {
Name string `json:"name"`
Description string `json:"description"`
Parameters interface{} `json:"parameters"`
}
// ToolCall is a model request to call a tool.
type ToolCall struct {
Type string `json:"type"`
Function ToolCallFn `json:"function"`
}
// ToolCallFn holds name and arguments.
// Arguments may come from Ollama as a JSON object or as a JSON string.
type ToolCallFn struct {
Name string `json:"name"`
Arguments interface{} `json:"arguments"` // object or string
}
// QueryFromToolCall returns the "query" argument from a web_search tool call.
// Ollama may send arguments as a map or as a JSON string.
func QueryFromToolCall(tc ToolCall) string {
switch v := tc.Function.Arguments.(type) {
case map[string]interface{}:
if q, _ := v["query"].(string); q != "" {
return q
}
case string:
var m map[string]interface{}
if json.Unmarshal([]byte(v), &m) == nil {
if q, _ := m["query"].(string); q != "" {
return q
}
}
}
return ""
}
// ChatResponse is the Ollama /api/chat response.
type ChatResponse struct {
Message ChatMessage `json:"message"`
Done bool `json:"done"`
}
// Chat sends a chat request and returns the response.
func (c *Client) Chat(req *ChatRequest) (*ChatResponse, error) {
body, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("marshal request: %w", err)
}
url := c.BaseURL + "/api/chat"
httpReq, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("new request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
resp, err := c.HTTPClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("do request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("ollama returned %d: %s", resp.StatusCode, string(b))
}
var out ChatResponse
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, fmt.Errorf("decode response: %w", err)
}
return &out, nil
}
// WebSearchTool returns the tool definition for web_search (Tavily).
func WebSearchTool() Tool {
return Tool{
Type: "function",
Function: ToolFunc{
Name: "web_search",
Description: "Search the web for current information. Use when you need up-to-date or factual information from the internet.",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"query": map[string]interface{}{
"type": "string",
"description": "Search query",
},
},
"required": []string{"query"},
},
},
}
}

View File

@@ -0,0 +1,35 @@
package server
import (
"net/http"
"play-life-llm/internal/handler"
"play-life-llm/internal/ollama"
"play-life-llm/internal/tavily"
"github.com/gorilla/mux"
)
// Config holds server and client configuration.
type Config struct {
OllamaHost string
TavilyAPIKey string
DefaultModel string
}
// NewRouter returns an HTTP router with /health and /ask registered.
func NewRouter(cfg Config) http.Handler {
ollamaClient := ollama.NewClient(cfg.OllamaHost)
tavilyClient := tavily.NewClient(cfg.TavilyAPIKey)
askHandler := &handler.AskHandler{
Ollama: ollamaClient,
Tavily: tavilyClient,
DefaultModel: cfg.DefaultModel,
}
r := mux.NewRouter()
r.HandleFunc("/health", handler.Health).Methods(http.MethodGet)
r.Handle("/ask", askHandler).Methods(http.MethodPost)
return r
}

View File

@@ -0,0 +1,104 @@
package tavily
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"time"
)
const (
baseURL = "https://api.tavily.com"
searchPath = "/search"
timeout = 30 * time.Second
)
// Client calls Tavily Search API.
type Client struct {
APIKey string
HTTPClient *http.Client
}
// NewClient creates a Tavily client. apiKey is required for search.
func NewClient(apiKey string) *Client {
return &Client{
APIKey: apiKey,
HTTPClient: &http.Client{
Timeout: timeout,
},
}
}
// SearchRequest is the POST body for /search.
type SearchRequest struct {
Query string `json:"query"`
SearchDepth string `json:"search_depth,omitempty"` // basic, advanced, etc.
MaxResults int `json:"max_results,omitempty"`
}
// SearchResult is one result item.
type SearchResult struct {
Title string `json:"title"`
URL string `json:"url"`
Content string `json:"content"`
}
// SearchResponse is the Tavily search response.
type SearchResponse struct {
Query string `json:"query"`
Answer string `json:"answer,omitempty"`
Results []SearchResult `json:"results"`
}
// Search runs a web search and returns a single text suitable for passing to Ollama as tool result.
func (c *Client) Search(query string) (string, error) {
if c.APIKey == "" {
return "", fmt.Errorf("tavily: API key not set")
}
body, err := json.Marshal(SearchRequest{
Query: query,
MaxResults: 5,
})
if err != nil {
return "", fmt.Errorf("marshal request: %w", err)
}
url := baseURL + searchPath
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return "", fmt.Errorf("new request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+c.APIKey)
resp, err := c.HTTPClient.Do(req)
if err != nil {
return "", fmt.Errorf("do request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("tavily returned %d", resp.StatusCode)
}
var out SearchResponse
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return "", fmt.Errorf("decode response: %w", err)
}
// Build a single text for the model: prefer answer if present, else concatenate results.
if out.Answer != "" {
return out.Answer, nil
}
var b bytes.Buffer
for i, r := range out.Results {
if i > 0 {
b.WriteString("\n\n")
}
b.WriteString(r.Title)
b.WriteString(": ")
b.WriteString(r.Content)
}
return b.String(), nil
}

36
play-life-llm/main.go Normal file
View File

@@ -0,0 +1,36 @@
package main
import (
"log"
"net/http"
"os"
"play-life-llm/internal/server"
)
func main() {
ollamaHost := getEnv("OLLAMA_HOST", "http://localhost:11434")
tavilyAPIKey := getEnv("TAVILY_API_KEY", "")
port := getEnv("PORT", "8090")
defaultModel := getEnv("OLLAMA_MODEL", "llama3.1:70b")
cfg := server.Config{
OllamaHost: ollamaHost,
TavilyAPIKey: tavilyAPIKey,
DefaultModel: defaultModel,
}
router := server.NewRouter(cfg)
addr := ":" + port
log.Printf("play-life-llm listening on %s", addr)
if err := http.ListenAndServe(addr, router); err != nil {
log.Fatal(err)
}
}
func getEnv(key, defaultVal string) string {
if v := os.Getenv(key); v != "" {
return v
}
return defaultVal
}

View File

@@ -12,6 +12,7 @@
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="PlayLife" />
<meta name="screen-orientation" content="portrait" />
<meta name="description" content="Трекер продуктивности и изучения слов" />
<title>PlayLife</title>

View File

@@ -23,8 +23,34 @@ server {
proxy_cache_bypass $http_upgrade;
}
# Proxy admin panel to backend (must be before location /)
location ^~ /admin {
proxy_pass http://backend:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
# Proxy project endpoints to backend (must be before location /)
location ^~ /project/ {
proxy_pass http://backend:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
# Proxy other API endpoints to backend
location ~ ^/(playlife-feed|d2dc349a-0d13-49b2-a8f0-1ab094bfba9b|projects|project/priority|project/move|project/delete|project/create|message/post|webhook/|weekly_goals/setup|admin|admin\.html)$ {
location ~ ^/(playlife-feed|d2dc349a-0d13-49b2-a8f0-1ab094bfba9b|projects|message/post|webhook/|weekly_goals/setup|project_score_sample_mv/refresh|priorities/confirm)$ {
proxy_pass http://backend:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
@@ -60,6 +86,17 @@ server {
add_header Cache-Control "public, immutable";
}
# Статические HTML страницы (Terms и Privacy)
location = /terms {
try_files /terms.html =404;
add_header Cache-Control "public, max-age=3600";
}
location = /privacy {
try_files /privacy.html =404;
add_header Cache-Control "public, max-age=3600";
}
# Handle React Router (SPA)
location / {
try_files $uri $uri/ /index.html;

View File

@@ -1,12 +1,12 @@
{
"name": "play-life-web",
"version": "3.28.1",
"version": "4.17.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "play-life-web",
"version": "3.28.1",
"version": "4.17.1",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
@@ -15,6 +15,7 @@
"react": "^18.2.0",
"react-chartjs-2": "^5.2.0",
"react-circular-progressbar": "^2.2.0",
"react-day-picker": "^9.13.0",
"react-dom": "^18.2.0",
"react-easy-crop": "^5.5.6"
},
@@ -1618,6 +1619,12 @@
"node": ">=6.9.0"
}
},
"node_modules/@date-fns/tz": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.4.1.tgz",
"integrity": "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==",
"license": "MIT"
},
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
@@ -3871,6 +3878,22 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/date-fns": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/date-fns-jalali": {
"version": "4.1.0-0",
"resolved": "https://registry.npmjs.org/date-fns-jalali/-/date-fns-jalali-4.1.0-0.tgz",
"integrity": "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==",
"license": "MIT"
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -5923,6 +5946,27 @@
"react": ">=0.14.0"
}
},
"node_modules/react-day-picker": {
"version": "9.13.0",
"resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-9.13.0.tgz",
"integrity": "sha512-euzj5Hlq+lOHqI53NiuNhCP8HWgsPf/bBAVijR50hNaY1XwjKjShAnIe8jm8RD2W9IJUvihDIZ+KrmqfFzNhFQ==",
"license": "MIT",
"dependencies": {
"@date-fns/tz": "^1.4.1",
"date-fns": "^4.1.0",
"date-fns-jalali": "^4.1.0-0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "individual",
"url": "https://github.com/sponsors/gpbl"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/react-dom": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "play-life-web",
"version": "4.1.0",
"version": "6.27.3",
"type": "module",
"scripts": {
"dev": "vite",
@@ -15,6 +15,7 @@
"react": "^18.2.0",
"react-chartjs-2": "^5.2.0",
"react-circular-progressbar": "^2.2.0",
"react-day-picker": "^9.13.0",
"react-dom": "^18.2.0",
"react-easy-crop": "^5.5.6"
},

View File

@@ -0,0 +1,159 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Политика конфиденциальности - Play Life</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
line-height: 1.6;
color: #333;
background: #f5f5f5;
padding: 20px;
}
.container {
max-width: 800px;
margin: 0 auto;
background: white;
padding: 40px;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
h1 {
color: #4f46e5;
margin-bottom: 30px;
font-size: 2em;
}
h2 {
color: #1f2937;
margin-top: 30px;
margin-bottom: 15px;
font-size: 1.5em;
}
p {
margin-bottom: 15px;
text-align: justify;
}
ul {
margin-left: 20px;
margin-bottom: 15px;
}
li {
margin-bottom: 8px;
}
.last-updated {
color: #6b7280;
font-size: 0.9em;
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #e5e7eb;
}
</style>
</head>
<body>
<div class="container">
<h1>Политика конфиденциальности</h1>
<p><strong>Дата вступления в силу:</strong> 1 января 2024 года</p>
<h2>1. Введение</h2>
<p>Play Life ("мы", "наш", "нас") уважает вашу конфиденциальность и обязуется защищать ваши личные данные. Настоящая Политика конфиденциальности объясняет, как мы собираем, используем, храним и защищаем вашу информацию при использовании нашего приложения.</p>
<h2>2. Собираемая информация</h2>
<p>Мы собираем следующие типы информации:</p>
<h3>2.1. Информация, предоставляемая вами</h3>
<ul>
<li>Имя и адрес электронной почты при регистрации</li>
<li>Данные о ваших проектах, задачах и целях</li>
<li>Списки желаний и связанная информация</li>
<li>Словари и слова для изучения</li>
</ul>
<h3>2.2. Информация из интеграций</h3>
<ul>
<li><strong>Todoist:</strong> Информация о ваших задачах (только при подключении интеграции)</li>
<li><strong>Telegram:</strong> ID пользователя Telegram (только при подключении бота)</li>
<li><strong>Fitbit:</strong> Данные о физической активности, включая шаги, этажи и активные зоны минут (только при подключении интеграции)</li>
</ul>
<h3>2.3. Автоматически собираемая информация</h3>
<ul>
<li>Данные об использовании приложения (логи доступа, ошибки)</li>
<li>Техническая информация (версия браузера, тип устройства)</li>
</ul>
<h2>3. Использование информации</h2>
<p>Мы используем собранную информацию для:</p>
<ul>
<li>Предоставления и улучшения функциональности приложения</li>
<li>Обработки ваших запросов и транзакций</li>
<li>Отправки уведомлений и обновлений (если вы подписаны)</li>
<li>Обеспечения безопасности и предотвращения мошенничества</li>
<li>Соблюдения юридических обязательств</li>
</ul>
<h2>4. Хранение данных</h2>
<p>Ваши данные хранятся на защищенных серверах. Мы применяем соответствующие технические и организационные меры для защиты ваших данных от несанкционированного доступа, изменения, раскрытия или уничтожения.</p>
<h2>5. Обмен данными</h2>
<p>Мы не продаем и не передаем ваши личные данные третьим лицам, за исключением:</p>
<ul>
<li>Когда это необходимо для предоставления услуг (например, интеграции с Fitbit, Todoist, Telegram)</li>
<li>Когда это требуется по закону или по запросу государственных органов</li>
<li>С вашего явного согласия</li>
</ul>
<h2>6. Интеграции с третьими сторонами</h2>
<p>При использовании интеграций с Fitbit, Todoist или Telegram, ваши данные могут передаваться этим сервисам в соответствии с их политиками конфиденциальности:</p>
<ul>
<li><strong>Fitbit:</strong> Мы получаем доступ только к данным о физической активности (шаги, этажи, активные зоны минут) с вашего явного разрешения через OAuth.</li>
<li><strong>Todoist:</strong> Мы получаем доступ только к информации о завершенных задачах для синхронизации с вашими проектами.</li>
<li><strong>Telegram:</strong> Мы получаем только ваш Telegram ID для связи с ботом.</li>
</ul>
<h2>7. Ваши права</h2>
<p>Вы имеете право:</p>
<ul>
<li>Получить доступ к вашим личным данным</li>
<li>Исправить неточные данные</li>
<li>Удалить ваши данные</li>
<li>Отозвать согласие на обработку данных</li>
<li>Ограничить обработку ваших данных</li>
<li>Получить копию ваших данных в структурированном формате</li>
</ul>
<p>Для осуществления этих прав свяжитесь с нами через приложение.</p>
<h2>8. Cookies и аналогичные технологии</h2>
<p>Мы используем cookies и аналогичные технологии для улучшения работы приложения, анализа использования и персонализации контента. Вы можете управлять настройками cookies в вашем браузере.</p>
<h2>9. Безопасность</h2>
<p>Мы применяем различные меры безопасности для защиты ваших данных, включая шифрование, контроль доступа и регулярные проверки безопасности. Однако ни один метод передачи через Интернет или электронного хранения не является на 100% безопасным.</p>
<h2>10. Хранение данных</h2>
<p>Мы храним ваши данные до тех пор, пока это необходимо для предоставления услуг или до тех пор, пока вы не попросите нас удалить их. Некоторые данные могут храниться дольше в соответствии с требованиями законодательства.</p>
<h2>11. Дети</h2>
<p>Наше приложение не предназначено для лиц младше 13 лет. Мы сознательно не собираем личную информацию от детей младше 13 лет.</p>
<h2>12. Изменения в политике</h2>
<p>Мы можем периодически обновлять настоящую Политику конфиденциальности. Мы уведомим вас о любых существенных изменениях, разместив новую политику на этой странице и обновив дату "Последнее обновление".</p>
<h2>13. Контактная информация</h2>
<p>Если у вас есть вопросы или запросы относительно настоящей Политики конфиденциальности или обработки ваших данных, пожалуйста, свяжитесь с нами через приложение.</p>
<h2>14. Применимое законодательство</h2>
<p>Настоящая Политика конфиденциальности регулируется законодательством Российской Федерации, включая Федеральный закон "О персональных данных" № 152-ФЗ.</p>
<div class="last-updated">
<p><strong>Последнее обновление:</strong> 1 января 2024 года</p>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,128 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Условия использования - Play Life</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
line-height: 1.6;
color: #333;
background: #f5f5f5;
padding: 20px;
}
.container {
max-width: 800px;
margin: 0 auto;
background: white;
padding: 40px;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
h1 {
color: #4f46e5;
margin-bottom: 30px;
font-size: 2em;
}
h2 {
color: #1f2937;
margin-top: 30px;
margin-bottom: 15px;
font-size: 1.5em;
}
p {
margin-bottom: 15px;
text-align: justify;
}
ul {
margin-left: 20px;
margin-bottom: 15px;
}
li {
margin-bottom: 8px;
}
.last-updated {
color: #6b7280;
font-size: 0.9em;
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #e5e7eb;
}
</style>
</head>
<body>
<div class="container">
<h1>Условия использования</h1>
<p><strong>Дата вступления в силу:</strong> 1 января 2024 года</p>
<h2>1. Принятие условий</h2>
<p>Используя приложение Play Life, вы соглашаетесь с настоящими Условиями использования. Если вы не согласны с какими-либо условиями, пожалуйста, не используйте наше приложение.</p>
<h2>2. Описание сервиса</h2>
<p>Play Life — это приложение для отслеживания продуктивности и личных целей, которое позволяет пользователям:</p>
<ul>
<li>Отслеживать прогресс по проектам и задачам</li>
<li>Управлять списками желаний</li>
<li>Изучать слова и создавать словари</li>
<li>Интегрироваться с внешними сервисами (Todoist, Telegram, Fitbit)</li>
</ul>
<h2>3. Регистрация и учетные записи</h2>
<p>Для использования некоторых функций приложения требуется создание учетной записи. Вы обязуетесь:</p>
<ul>
<li>Предоставлять точную и актуальную информацию</li>
<li>Поддерживать безопасность вашей учетной записи</li>
<li>Нести ответственность за все действия, совершенные под вашей учетной записью</li>
<li>Немедленно уведомлять нас о любом несанкционированном использовании</li>
</ul>
<h2>4. Использование сервиса</h2>
<p>Вы соглашаетесь использовать Play Life только в законных целях и не будете:</p>
<ul>
<li>Нарушать какие-либо применимые законы или нормативные акты</li>
<li>Передавать вредоносное программное обеспечение или код</li>
<li>Пытаться получить несанкционированный доступ к сервису</li>
<li>Использовать сервис для спама или рассылки нежелательных сообщений</li>
<li>Нарушать права интеллектуальной собственности других лиц</li>
</ul>
<h2>5. Интеграции с третьими сторонами</h2>
<p>Play Life может интегрироваться с внешними сервисами (Todoist, Telegram, Fitbit). Использование этих интеграций регулируется условиями использования соответствующих сервисов. Мы не несем ответственности за действия или политики этих третьих сторон.</p>
<h2>6. Интеллектуальная собственность</h2>
<p>Все материалы, содержащиеся в Play Life, включая, но не ограничиваясь текстом, графикой, логотипами, иконками, изображениями, являются собственностью Play Life или их соответствующих владельцев и защищены законами об авторском праве.</p>
<h2>7. Конфиденциальность</h2>
<p>Использование ваших личных данных регулируется нашей <a href="/privacy.html">Политикой конфиденциальности</a>. Используя Play Life, вы соглашаетесь с обработкой ваших данных в соответствии с этой политикой.</p>
<h2>8. Отказ от ответственности</h2>
<p>Play Life предоставляется "как есть" без каких-либо гарантий, явных или подразумеваемых. Мы не гарантируем, что сервис будет бесперебойным, безопасным или безошибочным.</p>
<h2>9. Ограничение ответственности</h2>
<p>В максимальной степени, разрешенной законом, Play Life не несет ответственности за любые прямые, косвенные, случайные, особые или последующие убытки, возникающие в результате использования или невозможности использования сервиса.</p>
<h2>10. Изменения в условиях</h2>
<p>Мы оставляем за собой право изменять настоящие Условия использования в любое время. Изменения вступают в силу с момента их публикации. Продолжение использования сервиса после внесения изменений означает ваше согласие с новыми условиями.</p>
<h2>11. Прекращение использования</h2>
<p>Мы можем приостановить или прекратить ваш доступ к сервису в любое время, с уведомлением или без него, по любой причине, включая нарушение настоящих Условий использования.</p>
<h2>12. Применимое право</h2>
<p>Настоящие Условия использования регулируются и толкуются в соответствии с законодательством Российской Федерации.</p>
<h2>13. Контактная информация</h2>
<p>Если у вас есть вопросы относительно настоящих Условий использования, пожалуйста, свяжитесь с нами через приложение.</p>
<div class="last-updated">
<p><strong>Последнее обновление:</strong> 1 января 2024 года</p>
</div>
</div>
</body>
</html>

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More