diff --git a/backend/app/services/xp.py b/backend/app/services/xp.py index 6dfd4ef..3450a5f 100644 --- a/backend/app/services/xp.py +++ b/backend/app/services/xp.py @@ -161,8 +161,25 @@ return level +def coin_balance(db: Session, user_id: str) -> int: + """Баланс монет — сумма журнала coin_events (та же арифметика, что в саду). + + Считается здесь, а не через services.garden.balance(): garden импортирует + уровни отсюда, обратный импорт был бы циклическим. Нужно странице «Достижения» + (ТЗ 3.13, сад отключён в 0.58), где монеты показываются без вызова /api/garden. + """ + return int( + db.scalar( + select(func.coalesce(func.sum(CoinEvent.amount), 0)).where( + CoinEvent.user_id == user_id + ) + ) + or 0 + ) + + def xp_summary(db: Session, user_id: str) -> dict[str, Any]: - """Сводка для UI: XP, уровень, прогресс до следующего уровня.""" + """Сводка для UI: XP, уровень, прогресс до следующего уровня, монеты.""" xp = total_xp(db, user_id) level = level_from_xp(xp) current_floor = LEVEL_FACTOR * (level - 1) * level // 2 @@ -183,6 +200,7 @@ ) or 0 ), + "coins": coin_balance(db, user_id), } diff --git a/backend/tests/test_xp.py b/backend/tests/test_xp.py index bb3be54..3cbb705 100644 --- a/backend/tests/test_xp.py +++ b/backend/tests/test_xp.py @@ -3,8 +3,11 @@ from typing import Any from fastapi.testclient import TestClient +from sqlalchemy import select +from app.models import GardenItem from app.services.xp import compute_rarity +from tests.conftest import _test_session_factory def _create_task(client: TestClient, **patch: Any) -> int: @@ -32,6 +35,8 @@ assert summary["total_xp"] == 12 assert summary["level"] == 1 assert summary["completed_count"] == 1 + # Монеты: столько же, сколько XP (2 за создание + 10 за закрытие) + assert summary["coins"] == 12 def test_xp_weighted_by_priority_and_estimate(client: TestClient) -> None: @@ -163,6 +168,7 @@ summary = client.get("/api/xp").json() assert summary["total_xp"] == 5 assert summary["completed_count"] == 0 + assert summary["coins"] == 4 events = client.get("/api/xp/events").json() assert [e["kind"] for e in events] == ["create_project"] @@ -177,6 +183,7 @@ summary = client.get("/api/xp").json() assert summary["total_xp"] == 2 assert summary["completed_count"] == 0 + assert summary["coins"] == 2 state = client.get("/api/garden").json() assert state["balance"] == 2 @@ -195,3 +202,34 @@ second = client.post("/api/xp/daily") assert second.json() == {"granted": False, "coins": 0} assert client.get("/api/garden").json()["balance"] == 50 + + +# --- Монеты в /api/xp и отключённый сад (ТЗ 0.58) --- + + +def test_xp_summary_reports_coins(client: TestClient) -> None: + """Баланс монет виден из /api/xp — страница «Достижения» не зовёт /api/garden.""" + task_id = _create_task(client) + client.patch(f"/api/tasks/{task_id}", json={"status": "done"}) + client.post("/api/xp/daily") + + summary = client.get("/api/xp").json() + assert summary["coins"] == 12 + 50 + # Паритет двух источников баланса (garden.balance и монеты сводки XP) + assert summary["coins"] == client.get("/api/garden").json()["balance"] + + +def test_achievements_flow_does_not_create_plants(client: TestClient) -> None: + """Регресс (0.58): без вызова /api/garden растения не создаются. + + Растения рождаются лениво — в ensure_garden(), которую зовёт только + GET /api/garden. Страница «Достижения» ходит лишь в /api/xp и /api/xp/events. + """ + task_id = _create_task(client) + client.patch(f"/api/tasks/{task_id}", json={"status": "done"}) + client.get("/api/xp") + client.get("/api/xp/events") + + with _test_session_factory() as session: + plants = session.scalars(select(GardenItem)).all() + assert plants == [] diff --git a/docs/TZ.md b/docs/TZ.md index cbc58bd..ce629c6 100644 --- a/docs/TZ.md +++ b/docs/TZ.md @@ -4,7 +4,7 @@ | | | |---|---| -| Версия ТЗ | 0.57 | +| Версия ТЗ | 0.58 | | Дата | 2026-10-09 | | Статус | На обсуждении | @@ -20,6 +20,7 @@ - Несколько пользователей: данные (задачи, проекты, теги, документы, сад, XP/монеты, настройки) полностью изолированы по `user_id` — один пользователь не видит чужого. + Сад (0.58) в интерфейсе отключён, но его данные по-прежнему изолированы. - Авторизация — через централизованный SSO-сервер **auth.gnexus.space** с клиентской библиотекой **gnexus-gauth** (см. 6.4). Пользователь создаётся в БД при первом входе (email, avatar, locale — из SSO). - **Claim при первом логине** (миграция на мультиюзерность): все строки с `user_id = NULL` (наследие однопользовательской версии) и глобальные настройки @@ -246,8 +247,9 @@ пока владелец не создаст токен в «Настройках». С 0.42 Bearer-токен принимается и на REST `/api/*` — им ходит браузерное расширение (3.18). - Закрытие задачи агентом (`update_task`/`complete_task`) идёт по тому же общему - пути, что и в UI: спавн регулярной, XP, монеты, растение (3.5, 3.13); в открытых - вкладках — скромный тост о награде (3.14). + пути, что и в UI: спавн регулярной, XP, монеты (3.5, 3.13); в открытых + вкладках — скромный тост о награде (3.14). Растение больше не выдаётся — + сад отключён в 0.58. ### 3.11. Локальные AI-функции @@ -291,7 +293,8 @@ - **Микронаграды за создание** (без конфетти — скромный тост «+N XP • +M монет»): создание задачи — +2 XP и +2 монеты, создание проекта — +5 XP и +4 монеты (0.48: монеты ×2). - В суммарный опыт (уровень) входят, растений в саду не рождают, счётчик + В суммарный опыт (уровень) входят, растений в саду не рождают (сад отключён + в 0.58 — выдача и так остановлена), счётчик закрытых задач не увеличивают; хранятся с `task_id = NULL` — удаление задачи или проекта награду не отнимает. - **Ежедневная награда за визит**: при открытии приложения фронт запрашивает @@ -299,14 +302,76 @@ (только монеты, XP нет), повторные визиты в те же сутки молча пропускаются. Тост «+50 монет» только в день первого визита. - **Уровень**: растёт из суммарного XP (уровень не падает); прогресс до следующего - уровня и **название звания** показываются в «Саду». Звания ботанические - (Росток, Сеянцев, Садовник, … Легенда сада — 13 званий; порог уровня - L = 100·L·(L−1)/2 XP). **Дерево уровней** — модалка со списком всех уровней: - звание, порог XP, что открывается (виды растений с ценой семени и - premium-пометкой, расширения карты); пройденные отмечены, текущий подсвечен. + уровня и **название звания** показываются на странице «Достижения» (0.58; ранее — + в саду). Звания ботанические (Росток, Сеянцев, Садовник, … Легенда сада — + 13 званий; порог уровня L = 100·L·(L−1)/2 XP). **Дерево званий** — модалка со + списком всех уровней: звание и порог XP; пройденные отмечены, текущий подсвечен. + В саду (код сохранён) та же модалка дополнительно показывает, что открывается + на уровне — виды растений с ценой семени и расширения карты. - **Момент награды**: при закрытии задачи — короткая конфетти-анимация и тост - «+N XP» с похвалой. Особые закрытия (редкое растение, крупная задача) — - усиленное «золотое» конфетти. + «+N XP • +N монет» с похвалой. Особые закрытия (крупная задача — от 25 XP, + «не хочется делать») — усиленное «золотое» конфетти. Редкость закрытия + (см. ниже) в 0.58 в UI не показывается, но по-прежнему пишется в `xp_events` — + как история для возврата сада. +- **Страница «Достижения»** (`/achievements`, 0.58): весь видимый прогресс без + сада. Бейджи шапки — уровень с названием звания, суммарный XP и **монеты**; + полоса прогресса до следующего уровня; кнопка «Дерево званий»; справка + «Как это работает»; **ачивки с тирами** (см. ниже) — тот же блок, что и в саду + (общий компонент, логика не дублируется). Данные — `GET /api/tasks`, + `GET /api/xp`, `GET /api/xp/events`; `GET /api/garden` **не вызывается** (это и + есть механизм отключения выдачи растений, см. ниже). Страница слушает SSE + `xp.changed` и `task.changed`. Пункт меню — между «Проектами» и «Статистикой» + (место прежнего «Сада»), путь `/garden` редиректит на `/achievements` + (старые ссылки и закладки PWA не ломаются). +- **Монеты**: начисляются при закрытии задачи — xp целиком (0.48: было + `floor(xp / 2)`; заголовок `X-Earned-Coins` рядом с XP в тосте), за создание + задачи/проекта и бонусом **50 × L** при достижении уровня L (0.48: было + 25 × L). Ежедневная награда не менялась (50 монет). + Хранение — таблица `coin_events` (журнал заработка и трат; баланс = + сумма amount). Траты — отрицательные события (`source = shop`); продажи нет + («только позитив»). Баланс виден на «Достижениях» — `GET /api/xp` отдаёт поле + `coins` (0.58). Пока сад отключён, тратить монеты негде: они копятся и + пригодятся, когда сад вернётся. +- **Ачивки с тирами**: у каждой ачивки ступени I/II/III (например, 10/50/100 + задач), прогресс к следующей ступени виден всегда. Набор: вехи закрытий, + уровни, макс. задач за день, разные проекты, суммарные часы, строгие + дедлайны, закрытия через выдачу задачи (см. 3.9), регулярная, проект. + Ачивки считаются на фронте из задач и `xp_events` и показываются на + «Достижениях» (0.58) — общий блок с садом (`components/AchievementsPanel.vue`). + **Описание по клику (0.49)**: карточка-ачивка — триггер GnPopover кита, + клик открывает панель с кратким описанием условия (`progress.achHint`, + i18n ×3; тултип по hover не годится — не работает на тач-экранах). +- **Связка с выдачей задачи** (3.9): закрытие задачи, открытой из выдачи, помечается + (`earned_via`) и празднуется меткой «выбрал и сделал» + счётчик-ачивкой; + XP при этом не меняется. +- **Страница «Статистика»**: закрываемость за выбранный **день / неделю / месяц** + (период листается стрелками назад/вперёд). Метрики — закрытые задачи, XP, + созданные задачи, часы работы, «дни с результатом» — каждая со сравнением + с **предыдущим аналогичным периодом** (дельта и проценты). Анимированный + **сравнительный** график закрытых задач по бакетам периода (часы / дни недели / + дни месяца): текущий и предыдущий период — пары столбиков. **Сетка активности** + «как в гитхабе» за полгода или год (переключатель диапазона; цвет по закрытым + задачам или XP, переключается). + Данные считаются из `xp_events` и дат задач, без новых эндпоинтов. +- **Метка «ментально сложная»**: у незакрытой задачи есть кнопка-переключатель + **«Не хочется делать»** (warning-стиль) — она ставит метку «ментально сложная»: + трудность не по объёму, а из-за требуемых волевых усилий. Метку можно снять до + закрытия, после закрытия она не меняется. Закрытие такой задачи даёт **+10 XP** + (сила воли) и всегда празднуется золотым конфетти; ачивка **«Преодоление»** + (5/25/50 закрытых «ментально сложных») — на «Достижениях». +- **Справка «Как это работает»**: кнопка на «Достижениях» (и, при возврате сада, + на странице сада) открывает модалку с объяснением механик (XP, уровни, + монеты, ачивки, принцип «только позитив»); параграфы — `progress.about`. +- **Сад отключён (0.58)**: сад не готов к использованию и недостаточно проработан, + а сервис расширяется — приоритет отдан основному функционалу. Страница убрана из + интерфейса, `/garden` редиректит на `/achievements`, код сохранён для возврата + (по прецеденту представления «дерево», 3.6). **Растения больше не выдаются**: + `GardenItem` создаётся лениво — в `ensure_garden()`, которую зовёт только + `GET /api/garden`; UI этот эндпоинт не вызывает, поэтому выдача прекращается + сама. Модель `GardenItem`, журнал `coin_events` и накопленная редкость + (`xp_events.rarity`) сохраняются — сад вернётся с прежним состоянием. Всё + описанное ниже — **исторический блок** (как сад устроен), актуальны только + XP, уровни, монеты и ачивки. - **Сад** (отдельная страница): живая **сцена** — графическое представление сада с домиком в центре; вокруг сад на мелкой сетке (ячейка ~20px, привязка при перемещении, наложения разрешены). Каждая закрытая задача — растение в саду; @@ -331,18 +396,11 @@ плавная доводка к центру клетки. Бросок мимо сада или на запрещённую клетку возвращает объект на исходную позицию анимацией **без запроса** к API (валидность фронт проверяет сам; бэк остаётся источником истины). -- **Две награды: опыт и монеты.** Опыт — «ранг садовника»: только растёт, ничего - не открывает в магазине напрямую. Монеты — «валюта сада»: тратятся. Общий - принцип: **контент открывается за опыт, покупается за монеты** — опыт (уровень) - открывает виды растений и землю, монеты покупают рост растений, декорации и - расширения карты. -- **Монеты**: начисляются при закрытии задачи — xp целиком (0.48: было - `floor(xp / 2)` — экономика сада ускорена примерно вдвое; заголовок - `X-Earned-Coins` рядом с XP в тосте), и бонусом **50 × L** при достижении - уровня L (0.48: было 25 × L). Ежедневная награда не менялась (50 монет). - Хранение — таблица `coin_events` (журнал заработка и трат; баланс = - сумма amount). Траты — отрицательные события (`source = shop`); продажи нет - («только позитив»). +- **Две награды: опыт и монеты.** (Исторический блок сада.) Опыт — «ранг + садовника»: только растёт, ничего не открывает в магазине напрямую. Монеты — + «валюта сада»: тратятся. Общий принцип: **контент открывается за опыт, + покупается за монеты** — опыт (уровень) открывает виды растений и землю, + монеты покупают рост растений, декорации и расширения карты. - **Редкие растения**: редкость решается в момент закрытия по весу задачи — чем больше XP, тем выше шанс редкого; эпик-растения — только у крупных закрытий. Редкость хранится в `xp_events`, визуально — золото + бейдж. @@ -393,33 +451,6 @@ (ур. 3, 100 монет) возвращает размер прежнего старта 32×20, второе (ур. 12, 250 монет) — до 40×28; у существующих садов предметы, оказавшиеся за новой границей, при первом заходе переставляются по спирали к дому. -- **Ачивки с тирами**: у каждой ачивки ступени I/II/III (например, 10/50/100 - задач), прогресс к следующей ступени виден всегда. Набор: вехи закрытий, - уровни, макс. задач за день, разные проекты, суммарные часы, строгие - дедлайны, закрытия через выдачу задачи (см. 3.9), регулярная, проект. - **Описание по клику (0.49)**: карточка-ачивка — триггер GnPopover кита, - клик открывает панель с кратким описанием условия (`garden.achHint`, - i18n ×3; тултип по hover не годится — не работает на тач-экранах). -- **Связка с выдачей задачи** (3.9): закрытие задачи, открытой из выдачи, помечается - (`earned_via`) и празднуется меткой «выбрал и сделал» + счётчик-ачивкой; - XP при этом не меняется. -- **Страница «Статистика»**: закрываемость за выбранный **день / неделю / месяц** - (период листается стрелками назад/вперёд). Метрики — закрытые задачи, XP, - созданные задачи, часы работы, «дни с результатом» — каждая со сравнением - с **предыдущим аналогичным периодом** (дельта и проценты). Анимированный - **сравнительный** график закрытых задач по бакетам периода (часы / дни недели / - дни месяца): текущий и предыдущий период — пары столбиков. **Сетка активности** - «как в гитхабе» за полгода или год (переключатель диапазона; цвет по закрытым - задачам или XP, переключается). - Данные считаются из `xp_events` и дат задач, без новых эндпоинтов. -- **Метка «ментально сложная»**: у незакрытой задачи есть кнопка-переключатель - **«Не хочется делать»** (warning-стиль) — она ставит метку «ментально сложная»: - трудность не по объёму, а из-за требуемых волевых усилий. Метку можно снять до - закрытия, после закрытия она не меняется. Закрытие такой задачи даёт **+10 XP** - (сила воли) и всегда празднуется золотым конфетти; ачивка **«Преодоление»** - (5/25/50 закрытых «ментально сложных») — в саду. -- **Справка «О саде»**: кнопка на странице сада открывает модалку с объяснением - механик (XP, уровни, растения и редкости, ачивки, принцип «только позитив»). ### 3.14. Реактивность: серверные события (SSE) @@ -436,17 +467,20 @@ Трейлинг-debounce 300 мс коалесцирует бёрсты (закрытие задачи = 2–3 события). Виды подписываются на свои kind'ы: стек/список — `task.changed`, `task.deleted`, `detail.changed`, `project.changed`, `project.deleted`; - статистика — `xp.changed`, `task.changed`; сад — `garden.changed`, `xp.changed`, - `task.changed` (рефetch подавляется на время drag по сцене); страница задачи — + статистика и «Достижения» — `xp.changed`, `task.changed`; сад (код сохранён, + страница отключена в 0.58) — `garden.changed`, `xp.changed`, `task.changed` + (рефetch подавляется на время drag по сцене); страница задачи — перечитывается при `task.changed`/`detail.changed` своего `id`. + `garden.changed` продолжает публиковаться, но слушателей в UI больше нет — + событие безвредно (0.58). - **Точки публикации** (строго после коммита транзакции): HTTP-хендлеры задач, проектов, сада, XP-дейли и настроек; MCP-инструменты и фоновая детализация — broadcast (всем вкладкам). Список kind'ов: `task.changed {id}`, `task.deleted {id}`, `detail.changed {id}`, `project.changed {id}`, `project.deleted {id}`, `garden.changed {reason}`, `xp.changed {amount?, celebrate}`, `settings.changed`, `ready` (первый кадр), `ping`. - Упрощение: закрытие задачи через MCP не шлёт `garden.changed` — растение - создаётся лениво, refetch по `task.changed` покрывает. + Упрощение: закрытие задачи через MCP не шлёт `garden.changed` — refetch по + `task.changed` покрывает (сад отключён в 0.58, слушателей у события нет). - **Дедуп XP-тостов**: действие своей вкладки празднуется по заголовкам ответа (`X-Earned-XP`, ТЗ 3.13); SSE-эхо и эхо соседней вкладки молчат — окно активности 3 с (localStorage). Соседние вкладки показывают скромный тост @@ -487,7 +521,8 @@ манифест не читается), `black-translucent` статус-бар, короткий тайтл; - тап-подсветка WebKit отключена (у кита есть `:active`-состояния); - кнопка fullscreen сада скрыта, если API не поддерживается (iPhone Safari - умеет fullscreen только для video). + умеет fullscreen только для video). *(Сад отключён в 0.58 — правило + историческое, вернётся вместе с садом.)* ### 3.16. Вебхуки системы авторизации (0.35, профиль 0.36) @@ -571,12 +606,13 @@ мелкие контролы/инсеты — 3px; темы Verdant/Ember/Mono и дизайн-токены `--gn-*` доступны, но не используются пока в gntodo). - Кастомные (сырые) блоки интерфейса оформлены в том же стиле, что и компоненты - кита — те же бордеры/радиусы: дерево уровней и маркет сада, ачивки, график - и heatmap статистики, оверлеи сцены сада (кнопки зума, подписи объектов, - инвентарь), блок «свежий токен» настроек, полнотекстовые превью и миниатюры - картинок, tags-fieldset формы задачи. + кита — те же бордеры/радиусы: дерево званий и ачивки на «Достижениях», график + и heatmap статистики, блок «свежий токен» настроек, полнотекстовые превью + и миниатюры картинок, tags-fieldset формы задачи; при возврате сада — маркет, + дерево уровней с unlocks и оверлеи сцены (кнопки зума, подписи объектов, + инвентарь). - Смысловые состояния сырых блоков подчёркиваются подкрашенным бордером и лёгкой - подложкой в цвет состояния (текущий уровень сада, разблокированные ачивки) — + подложкой в цвет состояния (текущий уровень, разблокированные ачивки) — без `!important`-войн с китом: только каскадное переопределение поверх публичных классов. - Иконки в заголовках карточек — в цвете текста заголовка (раньше затемнялись). @@ -626,7 +662,9 @@ Мультиюзерность (с 0.34): на задачах, проектах, тегах, документах, XP/монетах, саде и настройках есть `user_id` (nullable — NULL забирается первым вошедшим, -см. 1.2); все запросы API/сервисов/MCP-тулов скоупятся по нему. +см. 1.2); все запросы API/сервисов/MCP-тулов скоупятся по нему. Модель сада +(0.58) сохранена без изменений — раздел отключён, данные ждут возврата; +XP/монеты и `rarity` продолжают начисляться, как раньше. ``` User — пользователь SSO: id (= user_id от SSO), email, name (display_name), @@ -674,15 +712,19 @@ XpEvent — начисление XP за закрытую задачу (идемпотентно по task_id): amount, rarity (common | rare | epic), via_options -CoinEvent — журнал монет сада (заработок и траты; баланс = Σ amount): +CoinEvent — журнал монет (заработок и траты; баланс = Σ amount): task_id (nullable), level (nullable — бонус уровня), - source (task | level | shop), amount (+/−), item_key (ключ - покупки для трат), created_at + source (task | level | daily | shop), amount (+/−), item_key + (ключ покупки для трат), created_at. В 0.58 источник баланса + для UI — `GET /api/xp` (поле coins); траты (source=shop) + возможны только из сада GardenItem — элемент сцены сада: kind (plant | decoration), ref_id (→ xp_events для растений; уникально в паре с kind), item_key (вид растения / ключ декорации), x, y (ячейки сетки), - stage (0 росток, 1 куст, 2 цветение) + stage (0 росток, 1 куст, 2 цветение). С 0.58 новые записи + не создаются: ленивая ensure_garden вызывается только из + `GET /api/garden`, который UI не зовёт AppSetting — per-user настройки (валюта, язык): PK (user_id, key), value AppSettingGlobal — таблица-наследие однопользовательской версии @@ -735,6 +777,7 @@ 5. ~~**Модель для Ollama**~~ — **решено 2026-09-21**: по умолчанию `qwen3.5:2b-q4_K_M` (проверена на M2); меняется конфигом (`OLLAMA_MODEL`) без правок кода. Ollama — внешний сервер, адрес — `OLLAMA_BASE_URL`. 6. **Практическое различие `cancelled` / `deferred`**: определения зафиксированы (3.4), но поведение в интерфейсе почти одинаковое — скрыть из активных списков и оставить доступным для возврата. Уточнить при проектировании представлений, нужны ли оба статуса или их поведение сольётся. 7. ~~**Анти-дюп награды за статус**~~ — **решено 2026-09-21**: закрытие (HTTP и MCP) идёт через общий путь; XP — один раз на задачу (grant_task_xp), спавн регулярной — один раз на цепочку (метка `spawned_at`), выход из done сбрасывает `done_at`. Повторные закрытия и обновления закрытой не дублируют награды и экземпляры — покрыто тестами. -8. ~~**Пиксель-арт сцена сада**~~ — **решено 2026-09-20**: сцена переведена на пиксель-арт (PixiJS 8, WebGL); спрайты генерируются из пиксель-матриц в коде (без внешних ассетов и лицензий), см. 3.13. -9. **Premium-виды в семенах**: premium-виды (сейчас кактус) рождаются только из rare/epic закрытий — «отпечаток крупной работы». Семена же продаются за монеты и дают common-растение; сейчас семя premium-вида купить можно. Оставить ли покупку семян premium-видов (простой доступ к виду) или запретить (эксклюзив rare/epic сохраняется) — решить. -10. **Самосоединяющийся забор**: секции изгороди автоматически соединяются с соседними секциями (маска соседей N/E/S/W → текстура); ручного поворота нет. Соединяются только соседние секции забора — диагонали не считаются. Подтвердить, что поведение устраивает. \ No newline at end of file +8. ~~**Пиксель-арт сцена сада**~~ — **решено 2026-09-20**: сцена переведена на пиксель-арт (PixiJS 8, WebGL); спрайты генерируются из пиксель-матриц в коде (без внешних ассетов и лицензий), см. 3.13. **Отложено вместе с садом (0.58)** — код сохранён, вернётся при доработке сада. +9. **Premium-виды в семенах** — **отложено вместе с садом (0.58)**: premium-виды (сейчас кактус) рождаются только из rare/epic закрытий — «отпечаток крупной работы». Семена же продаются за монеты и дают common-растение; сейчас семя premium-вида купить можно. Оставить ли покупку семян premium-видов (простой доступ к виду) или запретить (эксклюзив rare/epic сохраняется) — решить при возврате сада. +10. **Самосоединяющийся забор** — **отложено вместе с садом (0.58)**: секции изгороди автоматически соединяются с соседними секциями (маска соседей N/E/S/W → текстура); ручного поворота нет. Соединяются только соседние секции забора — диагонали не считаются. Подтвердить при возврате сада. +11. **Возврат сада** (0.58): когда основной функционал будет в порядке, вернуть сад к доработке — выдача растений включится сама, если UI снова начнёт звать `GET /api/garden`; вопросы — что переделать в саду (сцена, маркет, прогрессия) — решать тогда, на накопленных данных. \ No newline at end of file diff --git a/frontend/src/App.vue b/frontend/src/App.vue index e9f77e5..757df00 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -24,7 +24,7 @@ { id: 'options', label: t('nav.options'), icon: 'ph-shuffle', to: '/options' }, { id: 'list', label: t('nav.list'), icon: 'ph-list-bullets', to: '/list' }, { id: 'projects', label: t('nav.projects'), icon: 'ph-folders', to: '/projects' }, - { id: 'garden', label: t('nav.garden'), icon: 'ph-leaf', to: '/garden' }, + { id: 'achievements', label: t('nav.achievements'), icon: 'ph-trophy', to: '/achievements' }, { id: 'stats', label: t('nav.stats'), icon: 'ph-chart-bar', to: '/stats' }, // архив — редко используемый раздел, предпоследний пункт { id: 'archive', label: t('nav.archive'), icon: 'ph-archive', to: '/archive' }, diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 1d81895..3abf5c3 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -101,6 +101,8 @@ level_span: number level_percent: number completed_count: number + /** Баланс монет (ТЗ 3.13): отдаётся здесь, т.к. /api/garden UI больше не зовёт (0.58) */ + coins: number } export interface XpEvent { @@ -226,8 +228,8 @@ // Геймификация последнего закрывающего запроса (забирается через takeEarnedXp) export interface EarnedReward { xp: number - coins: number // монеты сада — вторая награда за закрытие - rarity: string // common | rare | epic + coins: number // монеты — вторая награда за закрытие (копятся на «Достижениях») + rarity: string // common | rare | epic — история для возврата сада (0.58), в UI не показывается options: boolean // закрыто из режима «3 вариантов» hard: boolean // «ментально сложная» — награда за силу воли } diff --git a/frontend/src/components/AchievementsPanel.vue b/frontend/src/components/AchievementsPanel.vue new file mode 100644 index 0000000..dd09279 --- /dev/null +++ b/frontend/src/components/AchievementsPanel.vue @@ -0,0 +1,198 @@ + + + + + diff --git a/frontend/src/gamification.ts b/frontend/src/gamification.ts index f28cb79..f86bc74 100644 --- a/frontend/src/gamification.ts +++ b/frontend/src/gamification.ts @@ -1,6 +1,6 @@ // Геймификация (ТЗ 3.13): микронаграда за закрытие задачи — конфетти -// и тост «+N XP» с похвалой. Только позитив: ничего не сгорает, серий нет. -// Особые закрытия (редкое растение, крупная задача, режим «3 вариантов») +// и тост «+N XP • +N монет» с похвалой. Только позитив: ничего не сгорает, +// серий нет. Особые закрытия (крупная задача, «не хочется делать») // празднуются «золотым» конфетти и расширенным тостом. import { takeCreatedReward, takeEarnedXp } from './api' @@ -49,11 +49,10 @@ window.setTimeout(() => layer.remove(), 2600) } -// Текст тоста: похвала + приписки за редкое растение, режим «3 вариантов» и силу воли +// Текст тоста: похвала + приписка за силу воли. +// rarity награды (история для возврата сада, 0.58) в тексте не участвует. function rewardText(r: EarnedReward): string { const parts: string[] = [] - if (r.rarity === 'epic') parts.push(i18n.global.t('gamify.epicPlant')) - else if (r.rarity === 'rare') parts.push(i18n.global.t('gamify.rarePlant')) if (r.hard) parts.push(i18n.global.t('gamify.willpowerText')) parts.push(randomPraise()) return parts.filter(Boolean).join(' ') @@ -64,8 +63,9 @@ export function celebrateEarned(): void { const reward = takeEarnedXp() if (!reward || !reward.xp) return - // «Ментально сложная» празднуется золотом всегда — закрыть через силу само по себе повод - const special = reward.rarity !== 'common' || reward.xp >= 25 || reward.hard + // «Ментально сложная» празднуется золотом всегда — закрыть через силу само по себе повод; + // крупное закрытие (xp >= 25) — тоже + const special = reward.xp >= 25 || reward.hard confetti(special) const titleParts = [`+${reward.xp} XP`, `+${reward.coins} ${i18n.global.t('gamify.coins')}`] if (reward.options) titleParts.push(i18n.global.t('gamify.chosen')) diff --git a/frontend/src/locales/en.ts b/frontend/src/locales/en.ts index e374db9..5dcbfe0 100644 --- a/frontend/src/locales/en.ts +++ b/frontend/src/locales/en.ts @@ -43,7 +43,7 @@ home: 'Home', stack: 'Inbox stack', list: 'Task list', - garden: 'Garden', + achievements: 'Achievements', stats: 'Statistics', projects: 'Projects', archive: 'Archive', @@ -51,34 +51,32 @@ settings: 'Settings', extension: 'Extension', }, - garden: { - title: 'Garden', + // Progress: shared by the "Achievements" page and the garden (the garden is + // disabled in 0.58 and will return — wording stays neutral, no plants) + progress: { + title: 'Achievements', level: 'Level {n}', xpTotal: '{n} XP', - plants: 'plants: {n}', levelProgress: 'To next level: {xp} / {span} XP', aboutOpen: 'How it works', - aboutTitle: 'How the garden works', - levelsTitle: 'Level tree', + aboutTitle: 'How it works', + levelsTitle: 'Rank tree', levelsHint: - 'XP thresholds and rewards of each level: new plant species (seed price) and map expansions. Levels grow from task XP — nothing expires.', + 'XP thresholds and ranks of each level. Levels grow from task XP — nothing expires and nothing is taken away.', levelCurrent: 'current', + achievements: 'Achievements', + coins: 'coins: {n}', + coinsHint: 'Coins for completed tasks, created tasks and projects, new levels — they accumulate', achDone: 'All steps completed (highest — {r})', achStep: 'Step: roman numeral, fraction — progress to the next', - rewardCreateTask: 'task created', - rewardCreateProject: 'project created', about: [ - 'Every completed task leaves a plant on the bed — the garden only grows and never shrinks.', + 'Every completed task earns experience and coins — progress only grows and never shrinks.', 'Completing a task earns XP: base 10 + a priority bonus (up to 15) + a size bonus from the time estimate (up to 15) + 10 for a “mentally hard” task — an honest reward for willpower. Bigger and more urgent work is worth more.', 'Earned XP raises your level — from Sprout to Garden Legend. Levels never drop: XP does not expire and is not taken away, even if a task is deleted.', - 'Sometimes a task yields a rare plant — the chance grows with the task weight. Epic plants only come from the biggest completions.', - 'The general rule: experience unlocks, coins buy. Experience (level) unlocks new plant species and land; coins are spent on plant growth, decorations and map expansion. Coins come from completed tasks (half the XP) and from reaching new levels.', + 'Coins come from completed tasks (as much as the XP), from creating tasks and projects, and from new levels. For now they just accumulate — they will be useful when the garden returns.', 'Achievements mark milestones: tasks completed, levels, different projects, hours of work, strict deadlines, “mentally hard” tasks. Each has steps I, II, III with always-visible progress.', 'A task completed from the “3 options” mode earns the “chose and did” mark. Closing a “don’t want to do it” task is always celebrated in gold — the willpower was worth it. Nothing expires, no streaks or penalties — only pleasant rewards.', ], - lastReward: 'Latest reward for: {title} — {date}', - emptyTitle: 'The garden is empty', - emptyText: 'Complete your first task — a plant will grow on the bed.', levels: [ 'Sprout', 'Seedling', @@ -94,7 +92,43 @@ 'Keeper of legends', 'Garden legend', ], - achievements: 'Achievements', + ach: { + firstDone: 'First sprout', + done10: 'Dozen', + level5: 'Heights', + sprinter: 'Sprinter', + explorer: 'All-rounder', + marathon: 'Marathoner', + deadlineBoss: 'Deadline boss', + chooser: 'Chose and did', + willpower: 'Willpower', + recurring: 'Rhythm of life', + projectDone: 'Project delivered', + }, + achHint: { + firstDone: 'Complete your first task — the milestone is yours.', + done10: 'Complete 10, 50 and 100 tasks — one tier per milestone.', + level5: 'Reach level 5, 10 and 15.', + sprinter: 'Complete 3, 10 and 25 tasks in a single day.', + explorer: 'Have completed tasks in 3, 5 and 8 different projects.', + marathon: 'Accumulate 10, 50 and 100 hours on tasks — actual or estimated time.', + deadlineBoss: 'Complete 5, 15 and 30 tasks with a strict deadline date.', + chooser: 'Complete 5, 25 and 50 tasks picked from the “3 options” mode.', + willpower: 'Complete 5, 25 and 50 tasks marked “don’t feel like doing it”.', + recurring: 'Complete a recurring task.', + projectDone: 'Finish a project: all of its tasks (three or more) completed.', + }, + }, + garden: { + title: 'Garden', + plants: 'plants: {n}', + levelsHint: + 'XP thresholds and rewards of each level: new plant species (seed price) and map expansions. Levels grow from task XP — nothing expires.', + rewardCreateTask: 'task created', + rewardCreateProject: 'project created', + lastReward: 'Latest reward for: {title} — {date}', + emptyTitle: 'The garden is empty', + emptyText: 'Complete your first task — a plant will grow on the bed.', coins: 'coins: {n}', coinsHint: 'Garden currency: spent on plant growth and decorations', shop: 'Market', @@ -157,32 +191,6 @@ greenhouse: 'Greenhouse', gazebo: 'Gazebo', }, - ach: { - firstDone: 'First sprout', - done10: 'Dozen', - level5: 'Heights', - sprinter: 'Sprinter', - explorer: 'All-rounder', - marathon: 'Marathoner', - deadlineBoss: 'Deadline boss', - chooser: 'Chose and did', - willpower: 'Willpower', - recurring: 'Rhythm of life', - projectDone: 'Project delivered', - }, - achHint: { - firstDone: 'Complete your first task — the first plant grows in your garden.', - done10: 'Complete 10, 50 and 100 tasks — one tier per milestone.', - level5: 'Reach gardener level 5, 10 and 15.', - sprinter: 'Complete 3, 10 and 25 tasks in a single day.', - explorer: 'Have completed tasks in 3, 5 and 8 different projects.', - marathon: 'Accumulate 10, 50 and 100 hours on tasks — actual or estimated time.', - deadlineBoss: 'Complete 5, 15 and 30 tasks with a strict deadline date.', - chooser: 'Complete 5, 25 and 50 tasks picked from the “3 options” mode.', - willpower: 'Complete 5, 25 and 50 tasks marked “don’t feel like doing it”.', - recurring: 'Complete a recurring task.', - projectDone: 'Finish a project: all of its tasks (three or more) completed.', - }, }, gamify: { praise: [ @@ -193,8 +201,6 @@ 'Keep it up!', 'One task down, mood up.', ], - rarePlant: 'A rare plant has grown on your bed!', - epicPlant: 'A legendary plant in your garden!', chosen: 'chose and did', willpower: 'willpower', willpowerText: 'Willpower! It was worth it.', diff --git a/frontend/src/locales/ru.ts b/frontend/src/locales/ru.ts index 2da7fec..2554523 100644 --- a/frontend/src/locales/ru.ts +++ b/frontend/src/locales/ru.ts @@ -44,7 +44,7 @@ home: 'Главная', stack: 'Стек входящих', list: 'Список задач', - garden: 'Сад', + achievements: 'Достижения', stats: 'Статистика', projects: 'Проекты', archive: 'Архив', @@ -52,34 +52,32 @@ settings: 'Настройки', extension: 'Расширение', }, - garden: { - title: 'Сад', + // Прогресс: общий для страницы «Достижения» и сада (сад отключён в 0.58, + // вернётся позже — тексты нейтральные, без растений и «садовника») + progress: { + title: 'Достижения', level: 'Уровень {n}', xpTotal: '{n} XP', - plants: 'растений: {n}', levelProgress: 'До уровня: {xp} / {span} XP', aboutOpen: 'Как это работает', - aboutTitle: 'Как устроен сад', - levelsTitle: 'Дерево уровней', + aboutTitle: 'Как это работает', + levelsTitle: 'Дерево званий', levelsHint: - 'Пороги XP и награды каждого уровня: новые виды растений (цена семени) и расширения карты. Уровень растёт из опыта за задачи — ничего не сгорает.', + 'Пороги XP и звания каждого уровня. Уровень растёт из опыта за задачи — ничего не сгорает и не отнимается.', levelCurrent: 'текущий', + achievements: 'Достижения', + coins: 'монет: {n}', + coinsHint: 'Монеты за закрытые задачи, создание задач и проектов, новые уровни — копятся', achDone: 'Все ступени пройдены (высшая — {r})', achStep: 'Ступень: римская — номер, дробь — прогресс к следующей', - rewardCreateTask: 'создание задачи', - rewardCreateProject: 'создание проекта', about: [ - 'Каждая закрытая задача оставляет на грядке растение — сад только растёт и никогда не уменьшается.', + 'Каждая закрытая задача приносит опыт и монеты — прогресс только растёт и никогда не уменьшается.', 'За закрытие начисляется XP: база 10 + бонус за приоритет задачи (до 15) + бонус за объём работы по оценке времени (до 15) + 10 за «ментально сложную» — честная награда за силу воли. Крупные и срочные дела ценнее.', 'Накопленный XP повышает уровень — от «Ростка» до «Легенды сада». Уровень не падает: XP не сгорает и не отнимается, даже если задачу удалить.', - 'Иногда задача приносит редкое растение — шанс тем выше, чем «весомее» задача. Эпические растения достаются только крупным закрытиям.', - 'Общий принцип: опыт открывает, монеты покупают. Опыт (уровень) открывает новые виды растений и землю; монеты тратятся на рост растений, декорации и расширение карты. Монеты приходят за закрытые задачи (половина XP) и за новые уровни.', + 'Монеты приходят за закрытые задачи (столько же, сколько XP), за создание задач и проектов и за новые уровни. Сейчас они копятся — пригодятся, когда сад вернётся.', 'Достижения отмечают вехи: закрытые задачи, уровни, разные проекты, часы работы, строгие дедлайны, «ментально сложные» задачи. У каждой ступени I, II, III — прогресс виден всегда.', 'Задача, закрытая из режима «3 вариантов», отмечается меткой «выбрал и сделал». Закрытие «не хочется делать» празднуется золотом — сила воли того стоила. Ничего не сгорает, серий и наказаний нет — только приятные награды.', ], - lastReward: 'Последняя награда за: {title} — {date}', - emptyTitle: 'Сад пока пуст', - emptyText: 'Закройте первую задачу — на грядке появится растение.', levels: [ 'Росток', 'Сеянцев', @@ -95,7 +93,43 @@ 'Хранитель легенд', 'Легенда сада', ], - achievements: 'Достижения', + ach: { + firstDone: 'Первый росток', + done10: 'Десяток', + level5: 'Высоты', + sprinter: 'Спринтер', + explorer: 'Разносторонний', + marathon: 'Марафонец', + deadlineBoss: 'Дедлайн-босс', + chooser: 'Выбрал и сделал', + willpower: 'Преодоление', + recurring: 'Ритм жизни', + projectDone: 'Проект под ключ', + }, + achHint: { + firstDone: 'Закройте первую задачу — веха открыта.', + done10: 'Закройте 10, 50 и 100 задач — по ступени за каждую веху.', + level5: 'Достигните 5, 10 и 15 уровня.', + sprinter: 'Закройте 3, 10 и 25 задач за один день.', + explorer: 'Имейте закрытые задачи в 3, 5 и 8 разных проектах.', + marathon: 'Накопите 10, 50 и 100 часов работы над задачами — по факту или оценке.', + deadlineBoss: 'Закройте 5, 15 и 30 задач со строгим дедлайном-датой.', + chooser: 'Закройте 5, 25 и 50 задач, взятых из режима «3 вариантов».', + willpower: 'Закройте 5, 25 и 50 задач с меткой «не хочется делать».', + recurring: 'Закройте регулярную задачу.', + projectDone: 'Завершите проект целиком: все его задачи (от трёх) закрыты.', + }, + }, + garden: { + title: 'Сад', + plants: 'растений: {n}', + levelsHint: + 'Пороги XP и награды каждого уровня: новые виды растений (цена семени) и расширения карты. Уровень растёт из опыта за задачи — ничего не сгорает.', + rewardCreateTask: 'создание задачи', + rewardCreateProject: 'создание проекта', + lastReward: 'Последняя награда за: {title} — {date}', + emptyTitle: 'Сад пока пуст', + emptyText: 'Закройте первую задачу — на грядке появится растение.', coins: 'монет: {n}', coinsHint: 'Валюта сада: тратится на рост растений и декорации', shop: 'Маркет', @@ -157,32 +191,6 @@ greenhouse: 'Теплица', gazebo: 'Беседка', }, - ach: { - firstDone: 'Первый росток', - done10: 'Десяток', - level5: 'Высоты', - sprinter: 'Спринтер', - explorer: 'Разносторонний', - marathon: 'Марафонец', - deadlineBoss: 'Дедлайн-босс', - chooser: 'Выбрал и сделал', - willpower: 'Преодоление', - recurring: 'Ритм жизни', - projectDone: 'Проект под ключ', - }, - achHint: { - firstDone: 'Закройте первую задачу — в саду вырастет первое растение.', - done10: 'Закройте 10, 50 и 100 задач — по ступени за каждую веху.', - level5: 'Достигните 5, 10 и 15 уровня садовника.', - sprinter: 'Закройте 3, 10 и 25 задач за один день.', - explorer: 'Имейте закрытые задачи в 3, 5 и 8 разных проектах.', - marathon: 'Накопите 10, 50 и 100 часов работы над задачами — по факту или оценке.', - deadlineBoss: 'Закройте 5, 15 и 30 задач со строгим дедлайном-датой.', - chooser: 'Закройте 5, 25 и 50 задач, взятых из режима «3 вариантов».', - willpower: 'Закройте 5, 25 и 50 задач с меткой «не хочется делать».', - recurring: 'Закройте регулярную задачу.', - projectDone: 'Завершите проект целиком: все его задачи (от трёх) закрыты.', - }, }, gamify: { praise: [ @@ -193,8 +201,6 @@ 'Так держать!', 'Минус одна задача, плюс настроение.', ], - rarePlant: 'На грядке редкое растение!', - epicPlant: 'Легендарное растение в саду!', chosen: 'выбрал и сделал', willpower: 'сила воли', willpowerText: 'Сила воли! Это того стоило.', diff --git a/frontend/src/locales/uk.ts b/frontend/src/locales/uk.ts index 689b38a..a562988 100644 --- a/frontend/src/locales/uk.ts +++ b/frontend/src/locales/uk.ts @@ -43,7 +43,7 @@ home: 'Головна', stack: 'Вхідний стек', list: 'Список задач', - garden: 'Сад', + achievements: 'Досягнення', stats: 'Статистика', projects: 'Проєкти', archive: 'Архів', @@ -51,34 +51,32 @@ settings: 'Налаштування', extension: 'Розширення', }, - garden: { - title: 'Сад', + // Прогрес: спільний для сторінки «Досягнення» та саду (сад вимкнено в 0.58, + // повернеться пізніше — тексти нейтральні, без рослин і «садівника») + progress: { + title: 'Досягнення', level: 'Рівень {n}', xpTotal: '{n} XP', - plants: 'рослин: {n}', levelProgress: 'До наступного рівня: {xp} / {span} XP', aboutOpen: 'Як це працює', - aboutTitle: 'Як влаштований сад', - levelsTitle: 'Дерево рівнів', + aboutTitle: 'Як це працює', + levelsTitle: 'Дерево звань', levelsHint: - 'Пороги XP та нагороди кожного рівня: нові види рослин (ціна насіння) й розширення мапи. Рівень росте з досвіду за завдання — нічого не згорає.', + 'Пороги XP та звання кожного рівня. Рівень росте з досвіду за завдання — нічого не згорає й не віднімається.', levelCurrent: 'поточний', + achievements: 'Досягнення', + coins: 'монет: {n}', + coinsHint: 'Монети за виконані завдання, створення завдань і проєктів, нові рівні — накопичуються', achDone: 'Усі щаблі пройдено (вищий — {r})', achStep: 'Щабель: римська — номер, дріб — прогрес до наступного', - rewardCreateTask: 'створення завдання', - rewardCreateProject: 'створення проєкту', about: [ - 'Кожне виконане завдання залишає на грядці рослину — сад лише зростає і ніколи не зменшується.', + 'Кожне виконане завдання приносить досвід і монети — прогрес лише зростає і ніколи не зменшується.', 'За виконання нараховується XP: база 10 + бонус за пріоритет (до 15) + бонус за обсяг за оцінкою часу (до 15) + 10 за «ментально складне» — чесна нагорода за силу волі. Великі й термінові справи цінніші.', 'Накопичений XP підвищує рівень — від «Паростка» до «Легенди саду». Рівень не падає: XP не згорає і не віднімається, навіть якщо завдання видалити.', - 'Іноді завдання приносить рідкісну рослину — шанс тим вищий, що «важче» завдання. Епічні рослини дістаються лише великим завершенням.', - 'Загальний принцип: досвід відкриває, монети купують. Досвід (рівень) відкриває нові види рослин і землю; монети витрачаються на ріст рослин, декорації та розширення карти. Монети надходять за виконані завдання (половина XP) та за нові рівні.', + 'Монети надходять за виконані завдання (стільки ж, скільки XP), за створення завдань і проєктів та за нові рівні. Поки що вони накопичуються — стануть у пригоді, коли сад повернеться.', 'Досягнення відзначають віхи: виконані завдання, рівні, різні проєкти, години роботи, строгі дедлайни, «ментально складні» завдання. У кожного ступені I, II, III — прогрес видно завжди.', 'Завдання, виконане з режиму «3 варіанти», відзначається меткою «обрав і зробив». Закриття «не хочеться робити» завжди святкується золотом — сила волі того варта. Нічого не згорає, серій і покарань немає — лише приємні нагороди.', ], - lastReward: 'Остання нагорода за: {title} — {date}', - emptyTitle: 'Сад поки порожній', - emptyText: 'Закрийте перше завдання — на грядці зросте рослина.', levels: [ 'Паросток', 'Сіянець', @@ -94,7 +92,43 @@ 'Хранитель легенд', 'Легенда саду', ], - achievements: 'Досягнення', + ach: { + firstDone: 'Перший паросток', + done10: 'Десятка', + level5: 'Висоти', + sprinter: 'Спринтер', + explorer: 'Різнобічний', + marathon: 'Марафонець', + deadlineBoss: 'Дедлайн-бос', + chooser: 'Обрав і зробив', + willpower: 'Подолання', + recurring: 'Ритм життя', + projectDone: 'Проєкт під ключ', + }, + achHint: { + firstDone: 'Закрийте перше завдання — віху відкрито.', + done10: 'Закрийте 10, 50 і 100 завдань — по ступені за кожну віху.', + level5: 'Досягніть 5, 10 і 15 рівня.', + sprinter: 'Закрийте 3, 10 і 25 завдань за один день.', + explorer: 'Майте закриті завдання в 3, 5 і 8 різних проєктах.', + marathon: 'Накопичите 10, 50 і 100 годин роботи над завданнями — за фактом або оцінкою.', + deadlineBoss: 'Закрийте 5, 15 і 30 завдань із суворим дедлайном-датою.', + chooser: 'Закрийте 5, 25 і 50 завдань, узятих із режиму «3 варіанти».', + willpower: 'Закрийте 5, 25 і 50 завдань із міткою «не хочеться робити».', + recurring: 'Закрийте регулярне завдання.', + projectDone: 'Завершіть проєкт повністю: усі його завдання (від трьох) закриті.', + }, + }, + garden: { + title: 'Сад', + plants: 'рослин: {n}', + levelsHint: + 'Пороги XP та нагороди кожного рівня: нові види рослин (ціна насіння) й розширення мапи. Рівень росте з досвіду за завдання — нічого не згорає.', + rewardCreateTask: 'створення завдання', + rewardCreateProject: 'створення проєкту', + lastReward: 'Остання нагорода за: {title} — {date}', + emptyTitle: 'Сад поки порожній', + emptyText: 'Закрийте перше завдання — на грядці зросте рослина.', coins: 'монет: {n}', coinsHint: 'Валюта саду: витрачається на ріст рослин і декорації', shop: 'Маркет', @@ -157,32 +191,6 @@ greenhouse: 'Теплиця', gazebo: 'Альтанка', }, - ach: { - firstDone: 'Перший паросток', - done10: 'Десятка', - level5: 'Висоти', - sprinter: 'Спринтер', - explorer: 'Різнобічний', - marathon: 'Марафонець', - deadlineBoss: 'Дедлайн-бос', - chooser: 'Обрав і зробив', - willpower: 'Подолання', - recurring: 'Ритм життя', - projectDone: 'Проєкт під ключ', - }, - achHint: { - firstDone: 'Закрийте перше завдання — у саду виросте перша рослина.', - done10: 'Закрийте 10, 50 і 100 завдань — по ступені за кожну віху.', - level5: 'Досягніть 5, 10 і 15 рівня садівника.', - sprinter: 'Закрийте 3, 10 і 25 завдань за один день.', - explorer: 'Майте закриті завдання в 3, 5 і 8 різних проєктах.', - marathon: 'Накопичите 10, 50 і 100 годин роботи над завданнями — за фактом або оцінкою.', - deadlineBoss: 'Закрийте 5, 15 і 30 завдань із суворим дедлайном-датою.', - chooser: 'Закрийте 5, 25 і 50 завдань, узятих із режиму «3 варіанти».', - willpower: 'Закрийте 5, 25 і 50 завдань із міткою «не хочеться робити».', - recurring: 'Закрийте регулярне завдання.', - projectDone: 'Завершіть проєкт повністю: усі його завдання (від трьох) закриті.', - }, }, gamify: { praise: [ @@ -193,8 +201,6 @@ 'Так тримати!', 'Мінус одне завдання, плюс настрій.', ], - rarePlant: 'На грядці зросла рідкісна рослина!', - epicPlant: 'Легендарна рослина в саду!', chosen: 'обрав і зробив', willpower: 'сила волі', willpowerText: 'Сила волі! Того варто було.', diff --git a/frontend/src/main.ts b/frontend/src/main.ts index c06e424..b381376 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -13,7 +13,7 @@ import { setPageTitle } from './taskui' import ListView from './views/ListView.vue' import ArchiveView from './views/ArchiveView.vue' -import GardenView from './views/GardenView.vue' +import AchievementsView from './views/AchievementsView.vue' import StatsView from './views/StatsView.vue' import OptionsView from './views/OptionsView.vue' import ProjectView from './views/ProjectView.vue' @@ -34,7 +34,10 @@ // слаг stack устарел (страница теперь главная) — старые ссылки ведём на / { path: '/stack', redirect: '/' }, { path: '/list', component: ListView, meta: { title: 'nav.list' } }, - { path: '/garden', component: GardenView, meta: { title: 'nav.garden' } }, + // сад отключён в 0.58 (не готов): страница убрана, прогресс и ачивки + // переехали на «Достижения»; старые ссылки ведём туда же + { path: '/garden', redirect: '/achievements' }, + { path: '/achievements', component: AchievementsView, meta: { title: 'nav.achievements' } }, { path: '/stats', component: StatsView, meta: { title: 'nav.stats' } }, { path: '/projects', component: ProjectsView, meta: { title: 'nav.projects' } }, // название проекта/задачи в тайтл подставляет вид после загрузки данных diff --git a/frontend/src/views/AchievementsView.vue b/frontend/src/views/AchievementsView.vue new file mode 100644 index 0000000..9a02c89 --- /dev/null +++ b/frontend/src/views/AchievementsView.vue @@ -0,0 +1,262 @@ + + + + + diff --git a/frontend/src/views/GardenView.vue b/frontend/src/views/GardenView.vue index 3b2556c..714d5d1 100644 --- a/frontend/src/views/GardenView.vue +++ b/frontend/src/views/GardenView.vue @@ -12,13 +12,17 @@ } from '../api' import { formatDate } from '../taskui' import { useRealtimeRefresh } from '../realtime' +import AchievementsPanel from '../components/AchievementsPanel.vue' import GardenScene from '../components/GardenScene.vue' defineOptions({ name: 'GardenView' }) // Сад (ТЗ 3.13): живая сцена — домик, растения (закрытые задачи), декорации. -// Принцип «контент открывается за опыт, покупается за монеты»: XP — уровень, -// монеты — валюта сада (рост растений, декорации, расширения карты). +// Страница отключена в 0.58 (из интерфейса убрана, /garden редиректит на +// /achievements), но код сохранён для возврата: UI её не зовёт, поэтому +// ленивая выдача растений (ensure_garden внутри GET /api/garden) не срабатывает. +// Общие с «Достижениями» блоки (XP, уровни, звания, ачивки) вынесены в +// progress.* и components/AchievementsPanel.vue, чтобы не дублировать логику. const { t, tm } = useI18n() @@ -32,7 +36,7 @@ // Справка «как это работает»: модалка с абзацами из локали const aboutOpen = ref(false) const aboutParagraphs = computed(() => { - const messages = tm('garden.about') + const messages = tm('progress.about') return Array.isArray(messages) ? (messages as unknown[]).filter((p): p is string => typeof p === 'string') : [] @@ -44,10 +48,10 @@ .sort((a, b) => (a.done_at! < b.done_at! ? 1 : -1)), ) -// Название уровня: 13 ботанических званий (garden.levels), дальше — последнее +// Название уровня: 13 званий (progress.levels), дальше — последнее function levelName(level: number): string { const idx = Math.min(level, 13) - 1 - return t(`garden.levels.${idx}`) + return t(`progress.levels.${idx}`) } // --- Дерево уровней: что открывается на каждом уровне --- @@ -94,79 +98,7 @@ return task?.title ?? doneTasks.value[0]?.title ?? '' }) -// --- Ачивки с тирами: ступени I/II/III внутри каждой, без «провала» --- - -interface Achievement { - key: string - icon: string - tiers: number[] - progress: number -} - -const ROMANS = ['I', 'II', 'III', 'IV'] - -// Текущая ступень: первая незакрытая; -1 — все пройдены -function currentTier(a: Achievement): number { - return a.tiers.findIndex((goal) => a.progress < goal) -} - -function tierLabel(a: Achievement): string { - const idx = currentTier(a) - const inHours = a.key === 'marathon' - const fmt = (n: number) => - inHours ? t('taskui.time.h', { h: Math.round(n / 60) }) : `${n}` - // Пройдено: галочка + высшая ступень («✓ III»); в процессе: ступень и прогресс - if (idx === -1) return `✓ ${ROMANS[a.tiers.length - 1]}` - return `${ROMANS[idx]} · ${fmt(a.progress)}/${fmt(a.tiers[idx])}` -} - -const achievements = computed(() => { - const done = doneTasks.value - const recurringDone = done.some((task) => task.task_type === 'recurring') - // Спринтер: максимум закрытых за один день - const perDay = new Map() - done.forEach((task) => { - const day = task.done_at!.slice(0, 10) - perDay.set(day, (perDay.get(day) ?? 0) + 1) - }) - const bestDay = Math.max(0, ...perDay.values()) - // Разносторонний: проекты, в которых есть закрытые задачи - const projectsWithDone = new Set(done.map((task) => task.project?.id).filter((id) => id != null)) - // Марафонец: суммарные минуты (факт, иначе оценка) - const totalMinutes = done.reduce( - (sum, task) => sum + (task.actual_minutes ?? task.estimated_minutes ?? 0), - 0, - ) - // Дедлайн-босс: закрытые со строгим дедлайном-датой - const strictDone = done.filter((task) => task.deadline_date).length - // «Выбрал и сделал»: закрыто через режим «3 вариантов» - const viaOptions = events.value.filter((e) => e.via_options).length - // Преодоление: закрытые «ментально сложные» (метка «не хочется делать») - const willpowerDone = done.filter((task) => task.mentally_hard).length - // проект закрыт: есть проект (от 3 задач), все задачи которого завершены - const projects = new Map() - tasks.value.forEach((task) => { - if (!task.project) return - const bucket = projects.get(task.project.id) ?? { total: 0, done: 0 } - bucket.total += 1 - if (task.status === 'done') bucket.done += 1 - projects.set(task.project.id, bucket) - }) - const projectDone = [...projects.values()].some((p) => p.total >= 3 && p.done === p.total) - return [ - { key: 'firstDone', icon: 'ph-flower-lotus', tiers: [1], progress: Math.min(done.length, 1) }, - { key: 'done10', icon: 'ph-leaf', tiers: [10, 50, 100], progress: done.length }, - { key: 'level5', icon: 'ph-crown-simple', tiers: [5, 10, 15], progress: xp.value?.level ?? 1 }, - { key: 'sprinter', icon: 'ph-lightning', tiers: [3, 10, 25], progress: bestDay }, - { key: 'explorer', icon: 'ph-stack', tiers: [3, 5, 8], progress: projectsWithDone.size }, - { key: 'marathon', icon: 'ph-mountains', tiers: [600, 3000, 6000], progress: totalMinutes }, - { key: 'deadlineBoss', icon: 'ph-medal', tiers: [5, 15, 30], progress: strictDone }, - { key: 'chooser', icon: 'ph-shuffle', tiers: [5, 25, 50], progress: viaOptions }, - { key: 'willpower', icon: 'ph-brain', tiers: [5, 25, 50], progress: willpowerDone }, - { key: 'recurring', icon: 'ph-repeat', tiers: [1], progress: recurringDone ? 1 : 0 }, - { key: 'projectDone', icon: 'ph-flag-checkered', tiers: [1], progress: projectDone ? 1 : 0 }, - ] -}) +// --- Ачивки: общий блок (components/AchievementsPanel.vue) --- // --- Маркет: расширения, декорации, каталог видов --- @@ -297,10 +229,10 @@ - +

{{ p }}

- +

{{ t('garden.levelsHint') }}