diff --git a/backend/alembic/versions/a1f2c3d4e5f6_xp_events_table.py b/backend/alembic/versions/a1f2c3d4e5f6_xp_events_table.py new file mode 100644 index 0000000..6ee883d --- /dev/null +++ b/backend/alembic/versions/a1f2c3d4e5f6_xp_events_table.py @@ -0,0 +1,36 @@ +"""xp events table (gamification) + +Revision ID: a1f2c3d4e5f6 +Revises: 5245dee4bd6e +Create Date: 2026-09-20 12:00:00.000000 + +""" +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = 'a1f2c3d4e5f6' +down_revision: str | Sequence[str] | None = '5245dee4bd6e' +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Upgrade schema.""" + op.create_table('xp_events', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('task_id', sa.Integer(), nullable=True), + sa.Column('amount', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['task_id'], ['tasks.id'], ondelete='SET NULL'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('task_id') + ) + + +def downgrade() -> None: + """Downgrade schema.""" + op.drop_table('xp_events') \ No newline at end of file diff --git a/backend/app/api/tasks.py b/backend/app/api/tasks.py index 61c61e9..8b61c1f 100644 --- a/backend/app/api/tasks.py +++ b/backend/app/api/tasks.py @@ -2,7 +2,7 @@ from typing import Any, cast -from fastapi import APIRouter, BackgroundTasks, HTTPException, Query +from fastapi import APIRouter, BackgroundTasks, HTTPException, Query, Response from sqlalchemy import select from app.dependencies import DbDep, UserDep @@ -19,6 +19,7 @@ from app.services.detailing import apply_proposal, detail_task from app.services.options import pick_options from app.services.recurrence import spawn_next +from app.services.xp import grant_task_xp router = APIRouter(prefix="/api/tasks", tags=["tasks"]) @@ -101,7 +102,9 @@ @router.patch("/{task_id}", response_model=TaskOut) -async def update_task(task_id: int, schema: TaskUpdate, db: DbDep, user: UserDep) -> Task: +async def update_task( + task_id: int, schema: TaskUpdate, db: DbDep, user: UserDep, response: Response +) -> Task: task = _get_task_or_404(db, task_id) data = schema.model_dump(exclude_unset=True) @@ -140,6 +143,10 @@ spawned = spawn_next(db, task) if spawned is not None: db.flush() + # Геймификация (ТЗ 3.13): XP за закрытие — один раз на задачу + earned = grant_task_xp(db, task) + if earned: + response.headers["X-Earned-XP"] = str(earned) db.flush() db.refresh(task) # перечитать связи (project/tags) после обновления diff --git a/backend/app/api/xp.py b/backend/app/api/xp.py new file mode 100644 index 0000000..2d2ab68 --- /dev/null +++ b/backend/app/api/xp.py @@ -0,0 +1,16 @@ +"""API геймификации (ТЗ 3.13): XP, уровень, прогресс.""" + +from typing import Any + +from fastapi import APIRouter + +from app.dependencies import DbDep, UserDep +from app.services.xp import xp_summary + +router = APIRouter(prefix="/api/xp", tags=["xp"]) + + +@router.get("") +async def get_xp(db: DbDep, user: UserDep) -> dict[str, Any]: + """Сводка: суммарный XP, уровень, прогресс до следующего.""" + return xp_summary(db) diff --git a/backend/app/main.py b/backend/app/main.py index 85a9d6d..df8380d 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -11,7 +11,7 @@ from starlette.responses import JSONResponse, Response from app import mcp_server -from app.api import attachments, projects, tags, tasks +from app.api import attachments, projects, tags, tasks, xp from app.api.settings import router as settings_router from app.auth.routes import router as auth_router from app.config import get_settings @@ -62,6 +62,7 @@ app.include_router(tags.router) app.include_router(attachments.router) app.include_router(settings_router) +app.include_router(xp.router) # MCP-сервер для ИИ-агентов (M5) app.mount("/mcp", mcp_server.mcp.streamable_http_app()) diff --git a/backend/app/models.py b/backend/app/models.py index bace709..8dbca50 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -16,6 +16,7 @@ Integer, String, Text, + UniqueConstraint, ) from sqlalchemy.orm import Mapped, mapped_column, relationship @@ -152,3 +153,22 @@ key: Mapped[str] = mapped_column(String(100), primary_key=True) value: Mapped[str] = mapped_column(String(200), nullable=False) + + +class XpEvent(Base): + """Событие начисления XP за закрытую задачу (геймификация, ТЗ 3.13). + + Начисляется один раз при первом закрытии задачи; уникальность по task_id + делает начисление идемпотентным (повторное закрытие ничего не добавляет). + Задача может быть удалена — заработанный XP остаётся (only-positive). + """ + + __tablename__ = "xp_events" + __table_args__ = (UniqueConstraint("task_id"),) + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + task_id: Mapped[int | None] = mapped_column( + ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True, default=None + ) + amount: Mapped[int] = mapped_column(Integer) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) diff --git a/backend/app/services/xp.py b/backend/app/services/xp.py new file mode 100644 index 0000000..6a8038f --- /dev/null +++ b/backend/app/services/xp.py @@ -0,0 +1,90 @@ +"""Геймификация: XP за закрытые задачи и уровни (ТЗ 3.13). + +Только позитивное подкрепление: XP только начисляется, ничего не сгорает и +не отнимается (удаление задачи не трогает заработанное, стриков/серий нет). +""" + +from typing import Any + +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from app.models import Task, XpEvent + +# База за любую закрытую задачу +XP_BASE = 10 +# Бонус за приоритет (по градации шкалы 0-10: very_low/low/medium/high/urgent) +PRIORITY_BONUS = {"very_low": 0, "low": 2, "medium": 5, "high": 10, "urgent": 15} +# Бонус за оценку времени: больше работа — больше награда (минуты -> бонус, +# больше 240 -> последний бонус) +ESTIMATE_BONUS = ((15, 2), (60, 5), (240, 10), (float("inf"), 15)) + +# Уровень L достигается при XP >= LEVEL_FACTOR * (L-1) * L / 2: +# L2 = 100, L3 = 300, L4 = 600, L5 = 1000 ... на уровень L -> L+1 нужно 100*L +LEVEL_FACTOR = 100 + + +def priority_grade(priority: int | None) -> str: + if priority is None: + return "very_low" + if priority <= 2: + return "very_low" + if priority <= 4: + return "low" + if priority <= 6: + return "medium" + if priority <= 8: + return "high" + return "urgent" + + +def compute_task_xp(task: Task) -> int: + """XP за задачу: база + бонус приоритета + бонус объёма (оценки времени).""" + xp = XP_BASE + PRIORITY_BONUS[priority_grade(task.priority)] + minutes = task.estimated_minutes + if minutes is not None: + for limit, bonus in ESTIMATE_BONUS: + if minutes <= limit: + xp += bonus + break + return xp + + +def grant_task_xp(db: Session, task: Task) -> int: + """Начислить XP за закрытие задачи (один раз на задачу, идемпотентно).""" + existing = db.scalar(select(XpEvent).where(XpEvent.task_id == task.id)) + if existing is not None: + return 0 + event = XpEvent(task_id=task.id, amount=compute_task_xp(task)) + db.add(event) + db.flush() + return event.amount + + +def total_xp(db: Session) -> int: + return int(db.scalar(select(func.coalesce(func.sum(XpEvent.amount), 0))) or 0) + + +def level_from_xp(xp: int) -> int: + """Наибольший уровень L, при котором xp >= LEVEL_FACTOR*(L-1)*L/2.""" + level = 1 + while xp >= LEVEL_FACTOR * level * (level + 1) // 2: + level += 1 + return level + + +def xp_summary(db: Session) -> dict[str, Any]: + """Сводка для UI: XP, уровень, прогресс до следующего уровня.""" + xp = total_xp(db) + level = level_from_xp(xp) + current_floor = LEVEL_FACTOR * (level - 1) * level // 2 + next_floor = LEVEL_FACTOR * level * (level + 1) // 2 + span = next_floor - current_floor + return { + "total_xp": xp, + "level": level, + "level_xp": xp - current_floor, + "level_span": span, + "level_percent": round((xp - current_floor) / span * 100) if span else 100, + "completed_count": int(db.scalar(select(func.count(XpEvent.id))) or 0), + } diff --git a/backend/tests/test_xp.py b/backend/tests/test_xp.py new file mode 100644 index 0000000..b2441ca --- /dev/null +++ b/backend/tests/test_xp.py @@ -0,0 +1,69 @@ +"""Геймификация (ТЗ 3.13): XP за закрытие задач, уровни, идемпотентность.""" + +from typing import Any + +from fastapi.testclient import TestClient + + +def _create_task(client: TestClient, **patch: Any) -> int: + resp = client.post("/api/tasks", json={"title": "Задача для XP"}) + task_id = resp.json()["id"] + if patch: + resp2 = client.patch(f"/api/tasks/{task_id}", json=patch) + assert resp2.status_code == 200 + return task_id + + +def test_xp_granted_on_done(client: TestClient) -> None: + task_id = _create_task(client) + resp = client.patch(f"/api/tasks/{task_id}", json={"status": "done"}) + assert resp.status_code == 200 + # база 10, без приоритета и оценки — только база + assert resp.headers.get("X-Earned-XP") == "10" + + summary = client.get("/api/xp").json() + assert summary["total_xp"] == 10 + assert summary["level"] == 1 + assert summary["completed_count"] == 1 + + +def test_xp_weighted_by_priority_and_estimate(client: TestClient) -> None: + task_id = _create_task(client) + client.patch( + f"/api/tasks/{task_id}", json={"priority": 9, "estimated_minutes": 480} + ) + resp = client.patch(f"/api/tasks/{task_id}", json={"status": "done"}) + # 10 + urgent 15 + >4ч 15 = 40 + assert resp.headers.get("X-Earned-XP") == "40" + + +def test_xp_granted_once(client: TestClient) -> None: + task_id = _create_task(client) + client.patch(f"/api/tasks/{task_id}", json={"status": "done"}) + # возврат в работу и повторное закрытие — XP не растёт + client.patch(f"/api/tasks/{task_id}", json={"status": "in_progress"}) + resp = client.patch(f"/api/tasks/{task_id}", json={"status": "done"}) + assert "X-Earned-XP" not in resp.headers + assert client.get("/api/xp").json()["total_xp"] == 10 + + +def test_deleting_task_keeps_xp(client: TestClient) -> None: + task_id = _create_task(client) + client.patch(f"/api/tasks/{task_id}", json={"status": "done"}) + client.delete(f"/api/tasks/{task_id}") + assert client.get("/api/xp").json()["total_xp"] == 10 + + +def test_level_progression(client: TestClient) -> None: + # 9 закрытий по 10 XP = 90 — уровень 1; 10 закрытий = 100 — уровень 2 + for _ in range(9): + task_id = _create_task(client) + client.patch(f"/api/tasks/{task_id}", json={"status": "done"}) + assert client.get("/api/xp").json()["level"] == 1 + task_id = _create_task(client) + client.patch(f"/api/tasks/{task_id}", json={"status": "done"}) + summary = client.get("/api/xp").json() + assert summary["level"] == 2 + assert summary["level_xp"] == 0 + assert summary["level_span"] == 200 + assert summary["level_percent"] == 0 diff --git a/docs/TZ.md b/docs/TZ.md index d72549d..104cd15 100644 --- a/docs/TZ.md +++ b/docs/TZ.md @@ -4,7 +4,7 @@ | | | |---|---| -| Версия ТЗ | 0.7 | +| Версия ТЗ | 0.8 | | Дата | 2026-09-20 | | Статус | На обсуждении | @@ -157,6 +157,23 @@ - Даты, суммы и названия дней недели форматируются через Intl по активной локали. - Переводится только собственный UI: gnexus-ui-kit текстов не содержит (тексты через props). +### 3.13. Геймификация + +Маленькая дополнительная награда за выполнение задач — мотивация без негативных +последствий. Принцип **только позитив**: XP только начисляется, ничего не сгорает +и не отнимается; стрики/серии (которые «ломаются» при пропуске) не используются. + +- **XP за закрытие задачи**: база 10 + бонус за приоритет (0–15) + бонус за + объём по оценке времени (0–15). Начисление — один раз на задачу (идемпотентно); + удаление задачи не отнимает заработанное. Хранение — таблица `xp_events`. +- **Уровень**: растёт из суммарного XP (уровень не падает); прогресс до следующего + уровня показывается в «Саду». +- **Момент награды**: при закрытии задачи — короткая конфетти-анимация и тост + «+N XP» с похвалой. +- **Сад** (отдельная страница): каждая закрытая задача — растение на грядке; + сад только растёт. Рядом — уровень и **ачивки-вехи** (10/50 задач, уровень 5/10, + закрытая регулярная, закрытый проект) с видимым прогрессом. + ## 4. Нефункциональные требования | Требование | Значение | diff --git a/frontend/src/App.vue b/frontend/src/App.vue index f142ac1..577f3a4 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -5,15 +5,16 @@ import { api } from './api' import { resolveLocale } from './i18n/locale' import { setCurrency } from './taskui' +import GamifyBridge from './components/GamifyBridge.vue' import type { UserInfo } from './types' const route = useRoute() const { t } = useI18n() const user = ref(null) const accountUrl = ref('') - const navItems = computed(() => [ { id: 'home', label: t('nav.home'), icon: 'ph-house', to: '/' }, + { id: 'garden', label: t('nav.garden'), icon: 'ph-leaf', to: '/garden' }, { id: 'list', label: t('nav.list'), icon: 'ph-list-bullets', to: '/list' }, { id: 'projects', label: t('nav.projects'), icon: 'ph-folders', to: '/projects' }, { id: 'options', label: t('nav.options'), icon: 'ph-shuffle', to: '/options' }, @@ -81,6 +82,7 @@