diff --git a/.env.example b/.env.example index aa0c465..79a9a98 100644 --- a/.env.example +++ b/.env.example @@ -12,7 +12,7 @@ # Ollama (M2) OLLAMA_BASE_URL=http://localhost:11434 -OLLAMA_MODEL=qwen2.5:3b +OLLAMA_MODEL=qwen3.5:2b-q4_K_M # MCP-сервер (M5): bearer-токен для ИИ-агентов; пусто — /mcp закрыт MCP_TOKEN=change-me-mcp-token diff --git a/backend/alembic/versions/a5b6c7d8e9f0_project_archive.py b/backend/alembic/versions/a5b6c7d8e9f0_project_archive.py new file mode 100644 index 0000000..a2436bb --- /dev/null +++ b/backend/alembic/versions/a5b6c7d8e9f0_project_archive.py @@ -0,0 +1,36 @@ +"""Архив проектов (ТЗ 3.11): поле is_archived. + +«Закрыт» (relevance_status='archived') уходит: такие проекты переезжают +в архив (is_archived=True, relevance_status='active') — история сохраняется, +из рабочих видов пропадают. +""" + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "a5b6c7d8e9f0" +down_revision: str | Sequence[str] | None = "b3c4d5e6f7a8" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.add_column( + "projects", + sa.Column("is_archived", sa.Boolean(), nullable=False, server_default=sa.false()), + ) + op.execute( + "UPDATE projects SET is_archived = true, relevance_status = 'active'" + " WHERE relevance_status = 'archived'" + ) + + +def downgrade() -> None: + op.execute( + "UPDATE projects SET relevance_status = 'archived' WHERE is_archived = true" + ) + op.drop_column("projects", "is_archived") diff --git a/backend/alembic/versions/b3c4d5e6f7a8_xp_events_kind.py b/backend/alembic/versions/b3c4d5e6f7a8_xp_events_kind.py new file mode 100644 index 0000000..a0b8f69 --- /dev/null +++ b/backend/alembic/versions/b3c4d5e6f7a8_xp_events_kind.py @@ -0,0 +1,28 @@ +"""Награды за создание (ТЗ 3.13): поле kind в xp_events. + +kind='task' — закрытие задачи (растение в саду); kind='create_task' / +'create_project' — микронаграда за создание, растения не даёт. +""" + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "b3c4d5e6f7a8" +down_revision: str | Sequence[str] | None = "f2a3b4c5d6e7" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.add_column( + "xp_events", + sa.Column("kind", sa.String(length=20), nullable=False, server_default="task"), + ) + + +def downgrade() -> None: + op.drop_column("xp_events", "kind") diff --git a/backend/app/api/events.py b/backend/app/api/events.py new file mode 100644 index 0000000..574c606 --- /dev/null +++ b/backend/app/api/events.py @@ -0,0 +1,60 @@ +"""SSE-эндпоинт (ТЗ 3.14): поток событий для SPA. + +EventSource фронтенда держит соединение открытым; сервер шлёт события шины +и ping каждые PING_SECONDS секунд (keep-alive, чтобы прокси не рвал поток). +""" + +import asyncio +import json +from collections.abc import AsyncIterator +from typing import Any + +from fastapi import APIRouter +from starlette.requests import Request +from starlette.responses import StreamingResponse + +from app.dependencies import UserDep +from app.realtime import Event, bus + +router = APIRouter(prefix="/api/events", tags=["events"]) + +# keep-alive: прокси (Vite dev, nginx) закрывают «тихие» соединения +PING_SECONDS = 20.0 + + +def _sse_json(kind: str, data: dict[str, Any] | None = None) -> str: + payload: dict[str, Any] = {"kind": kind} + if data: + payload["data"] = data + return json.dumps(payload, ensure_ascii=False) + + +async def _event_source(request: Request, user_id: str) -> AsyncIterator[str]: + queue = bus.subscribe(user_id) + try: + yield f"data: {_sse_json('ready')}\n\n" + while True: + if await request.is_disconnected(): + return + try: + event: Event = await asyncio.wait_for(queue.get(), timeout=PING_SECONDS) + payload = _sse_json(event.kind, event.data) + except TimeoutError: + payload = _sse_json("ping") + yield f"data: {payload}\n\n" + finally: + bus.unsubscribe(user_id, queue) + + +@router.get("") +async def events(request: Request, user: UserDep) -> StreamingResponse: + """SSE-поток: события изменений + ping (keep-alive).""" + return StreamingResponse( + _event_source(request, str(user["user_id"])), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + # nginx в проде не буферизует стрим + "X-Accel-Buffering": "no", + }, + ) diff --git a/backend/app/api/garden.py b/backend/app/api/garden.py index 74c4c43..2b0bc12 100644 --- a/backend/app/api/garden.py +++ b/backend/app/api/garden.py @@ -8,6 +8,7 @@ from app.dependencies import DbDep, UserDep from app.models import GardenItem, Task, XpEvent +from app.realtime import publish from app.services import garden from app.services.garden import ( DECORATIONS, @@ -18,6 +19,7 @@ ensure_garden, grid_size, house_footprint, + make_seed_plant, next_expansion, species_catalog, ) @@ -97,6 +99,10 @@ for key, spec in DECORATIONS.items() ], "next_expansion": expansion, + # весь список расширений — для дерева уровней (что открывается на уровне) + "expansions": [ + {"key": key, **spec} for key, spec in sorted(EXPANSIONS.items()) + ], "expansions_bought": garden.expansions_bought(db), "species": species_catalog(level), "upgrade_costs": UPGRADE_COSTS, @@ -119,7 +125,8 @@ if item.kind != "decoration": raise HTTPException(status_code=400, detail="Plants cannot be stored") item.x, item.y = None, None - db.flush() + db.commit() + publish(str(user["user_id"]), "garden.changed", {"reason": "move"}) return {"ok": True} size = grid_size(db) if not (0 <= move.x < size["cols"] and 0 <= move.y < size["rows"]): @@ -129,7 +136,8 @@ ): raise HTTPException(status_code=400, detail="Cannot place on the house") item.x, item.y = move.x, move.y - db.flush() + db.commit() + publish(str(user["user_id"]), "garden.changed", {"reason": "move"}) return {"ok": True} @@ -149,7 +157,8 @@ ) garden.spend(db, cost, f"upgrade:{item.item_key}") item.stage += 1 - db.flush() + db.commit() + publish(str(user["user_id"]), "garden.changed", {"reason": "upgrade"}) return {"ok": True, "stage": item.stage, "cost": cost, "balance": balance(db)} @@ -158,6 +167,29 @@ item_key = buy.item_key level = level_from_xp(total_xp(db)) + # Семя растения: вид из каталога, открытие по уровню, посадка сразу + if item_key.startswith("seed:"): + species = item_key.removeprefix("seed:") + spec = next((s for s in species_catalog(level) if s["key"] == species), None) + if spec is None: + raise HTTPException(status_code=400, detail=f"Unknown species: {species}") + if level < spec["level"]: + raise HTTPException(status_code=403, detail=f"Unlocks at level {spec['level']}") + if not garden.can_afford(db, spec["seed_cost"]): + raise HTTPException( + status_code=402, detail=f"Not enough coins: need {spec['seed_cost']}" + ) + garden.spend(db, spec["seed_cost"], item_key) + item = make_seed_plant(db, species) + db.flush() + db.commit() + publish(str(user["user_id"]), "garden.changed", {"reason": "seed"}) + return { + "ok": True, + "item": {"id": item.id, "item_key": item.item_key, "x": item.x, "y": item.y}, + "balance": balance(db), + } + if item_key in EXPANSIONS: expansion = next_expansion(db) if expansion is None or expansion["key"] != item_key: @@ -171,6 +203,8 @@ raise HTTPException(status_code=402, detail=f"Not enough coins: need {spec['cost']}") garden.spend(db, spec["cost"], item_key) db.flush() + db.commit() + publish(str(user["user_id"]), "garden.changed", {"reason": "expansion"}) return {"ok": True, "grid": grid_size(db), "balance": balance(db)} if item_key in DECORATIONS: @@ -190,6 +224,8 @@ db.add(item) garden.spend(db, spec["cost"], item_key) db.flush() + db.commit() + publish(str(user["user_id"]), "garden.changed", {"reason": "shop"}) return { "ok": True, "item": {"id": item.id, "item_key": item.item_key, "x": item.x, "y": item.y}, diff --git a/backend/app/api/projects.py b/backend/app/api/projects.py index 65bb6c0..b087307 100644 --- a/backend/app/api/projects.py +++ b/backend/app/api/projects.py @@ -1,31 +1,51 @@ """API проектов M1.""" -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, HTTPException, Query, Response from sqlalchemy import select from app.dependencies import DbDep, UserDep -from app.models import Project +from app.models import CoinEvent, Project +from app.realtime import publish from app.schemas import ProjectCreate, ProjectOut, ProjectUpdate +from app.services.xp import coins_for_create, grant_create_xp router = APIRouter(prefix="/api/projects", tags=["projects"]) -VALID_RELEVANCE = {"active", "paused", "archived"} +# «Закрыт» (archived) больше нет — вместо него архив (ТЗ 3.11) +VALID_RELEVANCE = {"active", "paused"} @router.post("", response_model=ProjectOut) -async def create_project(schema: ProjectCreate, db: DbDep, user: UserDep) -> Project: +async def create_project( + schema: ProjectCreate, db: DbDep, user: UserDep, response: Response +) -> Project: exists = db.scalars(select(Project).where(Project.name == schema.name)).first() if exists is not None: raise HTTPException(status_code=409, detail="Project name already exists") project = Project(name=schema.name, priority=schema.priority, note=schema.note) db.add(project) db.flush() + # Микронаграда за создание (ТЗ 3.13): чуть больше XP, чем за задачу + grant_create_xp(db, "create_project") + db.add(CoinEvent(source="create_project", amount=coins_for_create("create_project"))) + response.headers["X-Created-XP"] = "5" + response.headers["X-Created-Coins"] = "2" + db.commit() + publish(str(user["user_id"]), "project.changed", {"id": project.id}) + publish(str(user["user_id"]), "xp.changed", {"celebrate": False}) return project @router.get("", response_model=list[ProjectOut]) -async def list_projects(db: DbDep, user: UserDep) -> list[Project]: - return list(db.scalars(select(Project).order_by(Project.id)).all()) +async def list_projects( + db: DbDep, user: UserDep, archived: bool = Query(False) +) -> list[Project]: + """Активные проекты; archived=true — список архива (история).""" + return list( + db.scalars( + select(Project).where(Project.is_archived == archived).order_by(Project.id) + ).all() + ) @router.get("/{project_id}", response_model=ProjectOut) @@ -50,13 +70,41 @@ ) for field, value in data.items(): setattr(project, field, value) + db.commit() + publish(str(user["user_id"]), "project.changed", {"id": project.id}) + return project + + +@router.post("/{project_id}/archive", response_model=ProjectOut) +async def archive_project(project_id: int, db: DbDep, user: UserDep) -> Project: + """Отправить проект в архив — вместе со всеми его задачами (ТЗ 3.11).""" + project = db.get(Project, project_id) + if project is None: + raise HTTPException(status_code=404, detail="Project not found") + project.is_archived = True + db.commit() + publish(str(user["user_id"]), "project.changed", {"id": project.id}) + return project + + +@router.post("/{project_id}/restore", response_model=ProjectOut) +async def restore_project(project_id: int, db: DbDep, user: UserDep) -> Project: + """Вернуть проект из архива — со всеми задачами и историей.""" + project = db.get(Project, project_id) + if project is None: + raise HTTPException(status_code=404, detail="Project not found") + project.is_archived = False + db.commit() + publish(str(user["user_id"]), "project.changed", {"id": project.id}) return project @router.delete("/{project_id}") -async def delete_project(project_id: int, db: DbDep) -> dict[str, bool]: +async def delete_project(project_id: int, db: DbDep, user: UserDep) -> dict[str, bool]: project = db.get(Project, project_id) if project is None: raise HTTPException(status_code=404, detail="Project not found") db.delete(project) + db.commit() + publish(str(user["user_id"]), "project.deleted", {"id": project_id}) return {"ok": True} diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py index 3b18269..5324941 100644 --- a/backend/app/api/settings.py +++ b/backend/app/api/settings.py @@ -6,6 +6,7 @@ from app.dependencies import DbDep, UserDep from app.models import AppSetting +from app.realtime import publish from app.schemas import CURRENCIES, LANGUAGES, AppSettingsOut, AppSettingsUpdate router = APIRouter(prefix="/api/settings", tags=["settings"]) @@ -48,6 +49,8 @@ if schema.language is not None: _upsert(db, LANGUAGE_KEY, schema.language) db.flush() + db.commit() + publish(str(user["user_id"]), "settings.changed") return AppSettingsOut( currency=_read_value(db, CURRENCY_KEY, CURRENCIES, DEFAULT_CURRENCY), language=_read_value(db, LANGUAGE_KEY, LANGUAGES + ("",), DEFAULT_LANGUAGE), diff --git a/backend/app/api/tasks.py b/backend/app/api/tasks.py index 5bd53c3..caa9fe6 100644 --- a/backend/app/api/tasks.py +++ b/backend/app/api/tasks.py @@ -3,10 +3,11 @@ from typing import Any, cast from fastapi import APIRouter, BackgroundTasks, HTTPException, Query, Response -from sqlalchemy import select +from sqlalchemy import Select, or_, select from app.dependencies import DbDep, UserDep from app.models import CoinEvent, Project, Tag, Task, utcnow +from app.realtime import publish from app.schemas import ( DEADLINE_PERIODS, RECUR_KINDS, @@ -20,7 +21,13 @@ from app.services.garden import coins_for_xp, level_bonus_coins from app.services.options import pick_options from app.services.recurrence import spawn_next -from app.services.xp import grant_task_xp, level_from_xp, total_xp +from app.services.xp import ( + coins_for_create, + grant_create_xp, + grant_task_xp, + level_from_xp, + total_xp, +) router = APIRouter(prefix="/api/tasks", tags=["tasks"]) @@ -34,6 +41,11 @@ return task +def _archived_project_ids(db: Any) -> Select[tuple[int]]: + """id проектов в архиве — их задачи в рабочих видах не показываются.""" + return select(Project.id).where(Project.is_archived.is_(True)) + + def _validate_parent(db: Any, task: Task | None, parent_id: int | None) -> None: """Родитель должен существовать; циклы в дереве запрещены.""" if parent_id is None: @@ -53,7 +65,7 @@ @router.post("") async def create_task( - schema: TaskCreate, db: DbDep, user: UserDep, background: BackgroundTasks + schema: TaskCreate, db: DbDep, user: UserDep, background: BackgroundTasks, response: Response ) -> dict[str, int]: """Быстрый захват: достаточно title — задача попадает в стек (raw, to_do). @@ -66,9 +78,17 @@ ) db.add(task) db.flush() + # Микронаграда за создание (ТЗ 3.13): без конфетти, просто тост — + # заголовки X-Created-* фронт буферизует отдельно от награды за закрытие + grant_create_xp(db, "create_task") + db.add(CoinEvent(source="create_task", amount=coins_for_create("create_task"))) + response.headers["X-Created-XP"] = "2" + response.headers["X-Created-Coins"] = "1" # Коммитим до планирования фоновой работы: BackgroundTasks выполняются ДО # teardown-коммита зависимости get_db, а detail_task открывает свою сессию. db.commit() + publish(str(user["user_id"]), "task.changed", {"id": task.id}) + publish(str(user["user_id"]), "xp.changed", {"celebrate": False}) background.add_task(detail_task, task.id) return {"id": task.id} @@ -82,6 +102,7 @@ project_id: int | None = Query(None), tag_id: int | None = Query(None), parent_id: int | None = Query(None), + include_archived: bool = Query(False), ) -> list[Task]: stmt = select(Task).order_by(Task.created_at.desc()) if detail_state: @@ -90,6 +111,11 @@ stmt = stmt.where(Task.status == status) if project_id: stmt = stmt.where(Task.project_id == project_id) + elif not include_archived: + # задачи архивных проектов — только в истории (ТЗ 3.11) + stmt = stmt.where( + or_(Task.project_id.is_(None), Task.project_id.not_in(_archived_project_ids(db))) + ) if parent_id is not None: stmt = stmt.where(Task.parent_task_id == parent_id) if tag_id: @@ -136,6 +162,7 @@ task.tags = list(tags) # Пометка источника закрытия (геймификация): не поле задачи, в setattr не идёт earned_via = data.pop("earned_via", None) + earned_event = None for field, value in data.items(): setattr(task, field, value) @@ -170,9 +197,20 @@ ) ) response.headers["X-Earned-Coins"] = str(coins) + earned_event = event db.flush() db.refresh(task) # перечитать связи (project/tags) после обновления + # События после коммита: подписчик SSE может сразу перечитать данные + db.commit() + publish(str(user["user_id"]), "task.changed", {"id": task.id}) + if earned_event is not None: + publish( + str(user["user_id"]), + "xp.changed", + {"amount": earned_event.amount, "rarity": earned_event.rarity, "celebrate": True}, + ) + publish(str(user["user_id"]), "garden.changed", {"reason": "task"}) return task @@ -193,6 +231,8 @@ task.approved_at = utcnow() db.flush() db.refresh(task) + db.commit() + publish(str(user["user_id"]), "task.changed", {"id": task.id}) return task @@ -206,6 +246,7 @@ # Коммит до фоновой работы, чтобы worker не прочитал старое состояние # и его запись не перезаписалась teardown-коммитом (см. create_task). db.commit() + publish(str(user["user_id"]), "task.changed", {"id": task.id}) background.add_task(detail_task, task.id) return task @@ -219,4 +260,6 @@ @router.delete("/{task_id}") async def delete_task(task_id: int, db: DbDep, user: UserDep) -> dict[str, bool]: db.delete(_get_task_or_404(db, task_id)) + db.commit() + publish(str(user["user_id"]), "task.deleted", {"id": task_id}) return {"ok": True} diff --git a/backend/app/api/xp.py b/backend/app/api/xp.py index ce35eae..968e613 100644 --- a/backend/app/api/xp.py +++ b/backend/app/api/xp.py @@ -1,11 +1,12 @@ -"""API геймификации (ТЗ 3.13): XP, уровень, прогресс, события начисления.""" +"""API геймификации (ТЗ 3.13): XP, уровень, прогресс, события начисления, дейли.""" from typing import Any from fastapi import APIRouter from app.dependencies import DbDep, UserDep -from app.services.xp import xp_events, xp_summary +from app.realtime import publish +from app.services.xp import claim_daily, xp_events, xp_summary router = APIRouter(prefix="/api/xp", tags=["xp"]) @@ -20,3 +21,13 @@ async def get_xp_events(db: DbDep, user: UserDep) -> list[dict[str, Any]]: """Все события начисления: редкость растения, признак «из 3 вариантов», даты.""" return xp_events(db) + + +@router.post("/daily") +async def post_daily(db: DbDep, user: UserDep) -> dict[str, Any]: + """Ежедневная награда за визит: 50 монет раз в сутки (идемпотентно).""" + result = claim_daily(db) + if result.get("granted"): + db.commit() + publish(str(user["user_id"]), "xp.changed", {"celebrate": False}) + return result diff --git a/backend/app/config.py b/backend/app/config.py index bd6c71a..0d39e0b 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -22,8 +22,8 @@ session_cookie_name: str = "gntodo_session" # Ollama (M2) - ollama_base_url: str = "http://localhost:11434" - ollama_model: str = "gemma4:e2b-it-qat" + ollama_base_url: str = "http://192.168.1.130:11434" + ollama_model: str = "qwen3.5:2b-q4_K_M" # Хранилище вложений (M2) attachments_path: str = "data/attachments" diff --git a/backend/app/main.py b/backend/app/main.py index bc78401..2de5d34 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, garden, projects, tags, tasks, xp +from app.api import attachments, events, garden, 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 @@ -64,6 +64,7 @@ app.include_router(settings_router) app.include_router(xp.router) app.include_router(garden.router) +app.include_router(events.router) # MCP-сервер для ИИ-агентов (M5) app.mount("/mcp", mcp_server.mcp.streamable_http_app()) diff --git a/backend/app/mcp_server.py b/backend/app/mcp_server.py index 3bdf863..6308364 100644 --- a/backend/app/mcp_server.py +++ b/backend/app/mcp_server.py @@ -15,6 +15,7 @@ from app.db import get_session_factory from app.models import Attachment, Document, Project, Tag, Task, utcnow +from app.realtime import publish from app.schemas import DEADLINE_PERIODS, RECUR_KINDS from app.services.detailing import detail_task from app.services.recurrence import spawn_next @@ -109,6 +110,8 @@ task.tags = tags session.add(task) session.commit() + # Агент меняет данные вне UI — уведомляем все открытые SSE-соединения + publish(None, "task.changed", {"id": task.id, "source": "mcp"}) # Автодетализация — в фоновом потоке (LLM работает десятки секунд) threading.Thread(target=detail_task, args=(task.id,), daemon=True).start() return {"id": task.id, "title": task.title, "detail_state": task.detail_state} @@ -213,6 +216,7 @@ # Регулярная задача: следующий экземпляр по фиксированному календарю spawned = spawn_next(session, task) session.commit() + publish(None, "task.changed", {"id": task.id, "source": "mcp"}) result = _compact(task) if spawned is not None: result["spawned_next_id"] = spawned.id diff --git a/backend/app/models.py b/backend/app/models.py index 9673642..986e322 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -157,8 +157,10 @@ def document_id(self) -> int | None: return self.document.id if self.document else None - # Статус актуальности проекта + # Статус актуальности проекта («Закрыт» теперь выражается архивом) relevance_status: Mapped[str] = mapped_column(String(20), default="active") + # Архив (ТЗ 3.11): проект уезжает в историю со всеми задачами + is_archived: Mapped[bool] = mapped_column(Boolean, default=False) priority: Mapped[int | None] = mapped_column(Integer, nullable=True, default=None) tasks: Mapped[list[Task]] = relationship(back_populates="project") @@ -251,6 +253,9 @@ task_id: Mapped[int | None] = mapped_column( ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True, default=None ) + # Событие награды: закрытие задачи или создание (задача/проект). + # Растения в саду растут только из событий kind='task'. + kind: Mapped[str] = mapped_column(String(20), default="task", server_default="task") amount: Mapped[int] = mapped_column(Integer) # Редкость растения в саду — решается в момент закрытия по весу задачи rarity: Mapped[str] = mapped_column(String(16), default="common") diff --git a/backend/app/realtime.py b/backend/app/realtime.py new file mode 100644 index 0000000..f25e071 --- /dev/null +++ b/backend/app/realtime.py @@ -0,0 +1,82 @@ +"""Серверные события (SSE): шина публикации (ТЗ 3.14). + +События грубозернистые: подписчики получают «kind + данные» и сами решают, +что перечитать (refetch вместо патчей состояния). Издатели — HTTP-хендлеры, +MCP-инструменты (потоки FastMCP) и фоновый detail_task: публикация всегда +идёт через loop.call_soon_threadsafe, поэтому работает из любого потока. +""" + +import asyncio +import logging +from dataclasses import dataclass +from typing import Any + +logger = logging.getLogger(__name__) + +# Размер очереди подписчика: при переполнении события пропускаются — +# coarse-модель самовосстанавливается (клиент перечитывает данные целиком) +QUEUE_MAXSIZE = 64 + + +@dataclass(frozen=True) +class Event: + kind: str + data: dict[str, Any] | None = None + + +class EventBus: + """Реестр подписок по user_id (None при publish = broadcast всем).""" + + def __init__(self) -> None: + # user_id -> очереди его соединений (несколько вкладок = несколько очередей) + self._subscribers: dict[str, set[asyncio.Queue[Event]]] = {} + # Loop берётся лениво при первой подписке: lifespan в тестах не стартует, + # а после закрытия loop публикация молча сбрасывает его на новый + self._loop: asyncio.AbstractEventLoop | None = None + + def subscribe(self, user_id: str) -> asyncio.Queue[Event]: + queue: asyncio.Queue[Event] = asyncio.Queue(maxsize=QUEUE_MAXSIZE) + self._subscribers.setdefault(user_id, set()).add(queue) + try: + self._loop = asyncio.get_running_loop() + except RuntimeError: + pass # вне loop (не бывает для SSE-хендлера) — оставляем прежний + return queue + + def unsubscribe(self, user_id: str, queue: asyncio.Queue[Event]) -> None: + queues = self._subscribers.get(user_id) + if queues is not None: + queues.discard(queue) + if not queues: + del self._subscribers[user_id] + + def publish(self, user_id: str | None, kind: str, data: dict[str, Any] | None = None) -> None: + """Положить событие всем подписчикам (None = всем). Потокобезопасно.""" + if not self._subscribers or self._loop is None: + return # подписчиков нет — клиент догонит refetch'ем при подключении + event = Event(kind=kind, data=data) + try: + self._loop.call_soon_threadsafe(self._dispatch, user_id, event) + except RuntimeError: + # loop закрыт (перезапуск uvicorn --reload) — сбросим и ждём новую подписку + self._loop = None + + def _dispatch(self, user_id: str | None, event: Event) -> None: + if user_id is None: + targets = list(self._subscribers.values()) + else: + targets = [self._subscribers.get(user_id, set())] + for queues in targets: + for queue in queues: + try: + queue.put_nowait(event) + except asyncio.QueueFull: + logger.warning("SSE queue overflow for %r: event %r dropped", queue, event.kind) + + +bus = EventBus() + + +def publish(user_id: str | None, kind: str, data: dict[str, Any] | None = None) -> None: + """Точка публикации для хендлеров/агентов (см. ТЗ 3.14).""" + bus.publish(user_id, kind, data) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 1c54953..4888250 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -24,6 +24,7 @@ id: int name: str relevance_status: str + is_archived: bool priority: int | None note: str document_id: int | None diff --git a/backend/app/services/detailing.py b/backend/app/services/detailing.py index c52e4c1..abb3f33 100644 --- a/backend/app/services/detailing.py +++ b/backend/app/services/detailing.py @@ -15,6 +15,7 @@ from app.config import get_settings from app.db import get_session_factory from app.models import Project, Tag, Task +from app.realtime import publish logger = logging.getLogger(__name__) @@ -52,9 +53,18 @@ self.model = model or settings.ollama_model def generate(self, prompt: str) -> str: + # think: false — «думающие» модели (qwen3.5) с format:json кладут ответ + # в поле thinking, а response оставляют пустым; для черновых метаданных + # рассуждения не нужны response = httpx.post( f"{self.base_url}/api/generate", - json={"model": self.model, "prompt": prompt, "format": "json", "stream": False}, + json={ + "model": self.model, + "prompt": prompt, + "format": "json", + "stream": False, + "think": False, + }, timeout=180.0, ) response.raise_for_status() @@ -147,5 +157,7 @@ if proposal is not None: task.ai_proposal = proposal session.commit() + # LLM работает вне запроса — уведомляем все вкладки (ТЗ 3.14) + publish(None, "detail.changed", {"id": task_id}) finally: session.close() diff --git a/backend/app/services/garden.py b/backend/app/services/garden.py index abebb79..9ae5b7c 100644 --- a/backend/app/services/garden.py +++ b/backend/app/services/garden.py @@ -42,11 +42,16 @@ # Каталог декораций: ключ -> (цена, уникальная). Только косметика — без бонусов. DECORATIONS: dict[str, dict[str, Any]] = { "fence": {"cost": 30, "unique": False}, + "bush": {"cost": 25, "unique": False}, "flowerbed": {"cost": 40, "unique": False}, "lantern": {"cost": 80, "unique": True}, "bench": {"cost": 90, "unique": True}, "birdbath": {"cost": 120, "unique": True}, + "flag": {"cost": 150, "unique": True}, + "birdhouse": {"cost": 180, "unique": True}, + "gem": {"cost": 350, "unique": True}, "pond": {"cost": 250, "unique": True}, + "greenhouse": {"cost": 600, "unique": True}, "gazebo": {"cost": 400, "unique": True}, } @@ -59,6 +64,8 @@ "expansion_1": {"level": 3, "cost": 100}, "expansion_2": {"level": 5, "cost": 250}, "expansion_3": {"level": 8, "cost": 500}, + "expansion_4": {"level": 9, "cost": 750}, + "expansion_5": {"level": 11, "cost": 1000}, } # Виды растений: открываются уровнем («контент за опыт»); premium — только @@ -71,8 +78,34 @@ {"key": "ph-flower-lotus", "level": 2, "premium": False}, {"key": "ph-cactus", "level": 4, "premium": True}, {"key": "ph-tree", "level": 6, "premium": False}, + {"key": "ph-drop", "level": 7, "premium": False}, + {"key": "ph-sun", "level": 8, "premium": False}, + {"key": "ph-tree-structure", "level": 9, "premium": False}, + {"key": "ph-butterfly", "level": 10, "premium": False}, + {"key": "ph-campfire", "level": 11, "premium": True}, + {"key": "ph-sparkle", "level": 12, "premium": True}, + {"key": "ph-fire", "level": 13, "premium": True}, ] +# Семена растений в маркете: вид -> цена. Покупка сразу сажает растение +# (стадия 0) на свободную клетку по спирали; доступность — по уровню (SPECIES). +# Вид из семени — обычной редкости; редкие/эпик рождаются только из задач. +SEED_COSTS: dict[str, int] = { + "ph-flower": 30, + "ph-leaf": 40, + "ph-tree-evergreen": 60, + "ph-flower-lotus": 90, + "ph-cactus": 150, + "ph-tree": 200, + "ph-drop": 250, + "ph-sun": 300, + "ph-tree-structure": 400, + "ph-butterfly": 500, + "ph-campfire": 700, + "ph-sparkle": 900, + "ph-fire": 1200, +} + # Домик в центре сетки: footprint, который не занимают растения (ячейки) HOUSE_COLS = 4 HOUSE_ROWS = 3 @@ -113,8 +146,19 @@ def species_catalog(level: int) -> list[dict[str, Any]]: - """Каталог видов с пометкой «открыт на этом уровне».""" - return [{**s, "unlocked": level >= s["level"]} for s in SPECIES] + """Каталог видов с пометкой «открыт на этом уровне» и ценой семени.""" + return [ + {**s, "unlocked": level >= s["level"], "seed_cost": SEED_COSTS[s["key"]]} + for s in SPECIES + ] + + +def make_seed_plant(db: Session, species: str) -> GardenItem: + """Купленное семя: растение-росток в инвентаре (x/y NULL), пользователь + сам перетаскивает его на карту. Размещённое семя обратно убрать нельзя.""" + item = GardenItem(kind="plant", ref_id=None, item_key=species, x=None, y=None, stage=0) + db.add(item) + return item def species_for(level: int, task_id: int, rarity: str) -> str: @@ -184,7 +228,8 @@ if level not in granted: db.add(CoinEvent(source="level", level=level, amount=level_bonus_coins(level))) - # 2) Растения для событий XP без размещённого элемента + # 2) Растения для закрытых задач без размещённого элемента (события + # создания kind='create_*' растения не дают — только закрытия) placed_refs = set( db.scalars( select(GardenItem.ref_id).where( @@ -194,7 +239,9 @@ ) pending = [ e - for e in db.scalars(select(XpEvent).order_by(XpEvent.id)).all() + for e in db.scalars( + select(XpEvent).where(XpEvent.kind == "task").order_by(XpEvent.id) + ).all() if e.id not in placed_refs ] if pending: diff --git a/backend/app/services/options.py b/backend/app/services/options.py index 333dfe7..09bd96c 100644 --- a/backend/app/services/options.py +++ b/backend/app/services/options.py @@ -64,7 +64,8 @@ active_projects: set[int | None] = {None} for p in db.scalars(select(Project)).all(): - if p.relevance_status == "active": + # архивный проект в выдачу задачи не попадает, даже «активный» + if p.relevance_status == "active" and not p.is_archived: active_projects.add(p.id) used_projects: set[int | None] = set() diff --git a/backend/app/services/xp.py b/backend/app/services/xp.py index 54ba3c5..89b113d 100644 --- a/backend/app/services/xp.py +++ b/backend/app/services/xp.py @@ -5,12 +5,13 @@ """ import random +from datetime import UTC, datetime, time from typing import Any from sqlalchemy import func, select from sqlalchemy.orm import Session -from app.models import Task, XpEvent +from app.models import CoinEvent, Task, XpEvent # База за любую закрытую задачу XP_BASE = 10 @@ -22,6 +23,14 @@ # Бонус за «ментально сложную» (метка «не хочется делать»): награда за силу воли WILLPOWER_BONUS = 10 +# Микронаграды за создание (ТЗ 3.13): капля XP/монет, чтобы захват был заметен +XP_CREATE_TASK = 2 +COINS_CREATE_TASK = 1 +XP_CREATE_PROJECT = 5 +COINS_CREATE_PROJECT = 2 +# Ежедневная награда за визит (раз в сутки, только монеты) +DAILY_COINS = 50 + # Уровень L достигается при XP >= LEVEL_FACTOR * (L-1) * L / 2: # L2 = 100, L3 = 300, L4 = 600, L5 = 1000 ... на уровень L -> L+1 нужно 100*L LEVEL_FACTOR = 100 @@ -87,6 +96,7 @@ amount = compute_task_xp(task) event = XpEvent( task_id=task.id, + kind="task", amount=amount, rarity=compute_rarity(amount, random.random()), ) @@ -95,6 +105,40 @@ return event +def grant_create_xp(db: Session, kind: str) -> XpEvent: + """Микронаграда за создание (kind='create_task' | 'create_project'). + + Растения в саду не даёт (их растят только закрытия — см. ensure_garden); + task_id остаётся NULL — удаление задачи/проекта награду не отнимает. + """ + amount = XP_CREATE_TASK if kind == "create_task" else XP_CREATE_PROJECT + event = XpEvent(task_id=None, kind=kind, amount=amount, rarity="common") + db.add(event) + db.flush() + return event + + +def coins_for_create(kind: str) -> int: + return COINS_CREATE_TASK if kind == "create_task" else COINS_CREATE_PROJECT + + +def claim_daily(db: Session) -> dict[str, Any]: + """Ежедневная награда: 50 монет раз в сутки (граница дня — UTC). + + Идемпотентно: если за текущий день начисление уже было — granted=False. + """ + now = datetime.now(UTC) + day_start = datetime.combine(now.date(), time.min, tzinfo=UTC) + claimed = db.scalar( + select(CoinEvent).where(CoinEvent.source == "daily", CoinEvent.created_at >= day_start) + ) + if claimed is not None: + return {"granted": False, "coins": 0} + db.add(CoinEvent(source="daily", amount=DAILY_COINS)) + db.flush() + return {"granted": True, "coins": DAILY_COINS} + + def total_xp(db: Session) -> int: return int(db.scalar(select(func.coalesce(func.sum(XpEvent.amount), 0))) or 0) @@ -120,7 +164,10 @@ "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), + # Счётчик закрытых задач: события создания (kind='create_*') не считаются + "completed_count": int( + db.scalar(select(func.count(XpEvent.id)).where(XpEvent.kind == "task")) or 0 + ), } @@ -130,6 +177,7 @@ return [ { "task_id": r.task_id, + "kind": r.kind, "amount": r.amount, "rarity": r.rarity, "via_options": r.via_options, diff --git a/backend/tests/test_events.py b/backend/tests/test_events.py new file mode 100644 index 0000000..8c116e5 --- /dev/null +++ b/backend/tests/test_events.py @@ -0,0 +1,213 @@ +"""Серверные события (ТЗ 3.14): шина публикации и SSE-генератор. + +TestClient буферизует весь ответ (portal.call ждёт завершения приложения), +поэтому бесконечный SSE-стрим через client.stream нечитаем в принципе — +генератор `_event_source` тестируем напрямую, HTTP-публикации — перехватом +publish. +""" + +import asyncio +import threading +from collections.abc import AsyncIterator, Iterator +from typing import Any + +import pytest + +from app.api import events as events_api +from app.realtime import Event, bus, publish + + +@pytest.fixture(autouse=True) +def _fresh_bus() -> Iterator[None]: + """Шина между тестами пуста: asyncio.run юнит-тестов оставляет мёртвый loop.""" + bus._subscribers.clear() + bus._loop = None + yield + bus._subscribers.clear() + bus._loop = None + + +class _FakeRequest: + """Минимальный двойник Request для _event_source (is_disconnected → False).""" + + async def is_disconnected(self) -> bool: + return False + + +async def _anext_timeout(gen: AsyncIterator[str], timeout: float = 2.0) -> str: + return await asyncio.wait_for(gen.__anext__(), timeout=timeout) + + +# --- юниты шины --- + + +def test_dispatch_same_loop() -> None: + """Подписка и публикация в текущем loop: событие попадает в очередь.""" + + async def scenario() -> Any: + queue = bus.subscribe("u1") + try: + publish("u1", "task.changed", {"id": 1}) + return await asyncio.wait_for(queue.get(), timeout=2) + finally: + bus.unsubscribe("u1", queue) + + event = asyncio.run(scenario()) + assert event.kind == "task.changed" + assert event.data == {"id": 1} + + +def test_publish_from_thread() -> None: + """publish из другого потока (как MCP/detail_task) доезжает через call_soon_threadsafe.""" + + async def scenario() -> Any: + queue = bus.subscribe("u1") + try: + thread = threading.Thread( + target=publish, args=(None, "garden.changed"), kwargs={"data": {"reason": "shop"}} + ) + thread.start() + return await asyncio.wait_for(queue.get(), timeout=2) + finally: + bus.unsubscribe("u1", queue) + + event = asyncio.run(scenario()) + assert event.kind == "garden.changed" + + +def test_queue_overflow_skips() -> None: + """Переполнение очереди не бросает: событие пропускается (coarse-модель).""" + + async def scenario() -> None: + queue = bus.subscribe("u1") + try: + for i in range(100): + publish("u1", "task.changed", {"i": i}) + # очередь забита до maxsize — следующий publish не бросает + publish("u1", "task.changed", {"overflow": True}) + finally: + bus.unsubscribe("u1", queue) + + asyncio.run(scenario()) + + +def test_no_subscribers_is_noop() -> None: + """Publish без подписчиков — тихий no-op (клиент догонит refetch'ем).""" + publish(None, "task.changed", {"id": 1}) + + +# --- SSE-генератор --- + + +def test_event_source_ready_then_publish() -> None: + """Первый кадр ready; событие шины доезжает в стрим; отсоединение завершает.""" + + async def scenario() -> list[str]: + gen = events_api._event_source(_FakeRequest(), "u1") # type: ignore[arg-type] + frames: list[str] = [] + frames.append(await _anext_timeout(gen)) # subscribe внутри → loop забинден + publish("u1", "project.changed", {"id": 5}) + frames.append(await _anext_timeout(gen)) + await gen.aclose() + return frames + + frames = asyncio.run(scenario()) + assert frames[0] == "data: " + events_api._sse_json("ready") + "\n\n" + assert '"project.changed"' in frames[1] + assert '"id": 5' in frames[1] + # после aclose подписчик отписан + assert bus._subscribers == {} + + +def test_event_source_unsubscribe_on_disconnect() -> None: + """is_disconnected → генератор завершается, подписка снята (finally).""" + + class _Disconnecting: + def __init__(self) -> None: + self.calls = 0 + + async def is_disconnected(self) -> bool: + self.calls += 1 + return True # ready уходит без проверки, затем сразу disconnect + + async def scenario() -> None: + gen = events_api._event_source(_Disconnecting(), "u1") # type: ignore[arg-type] + await _anext_timeout(gen) + with pytest.raises(StopAsyncIteration): + await _anext_timeout(gen) + + asyncio.run(scenario()) + assert bus._subscribers == {} + + +def test_event_source_ping_on_idle() -> None: + """Тишина в шине дольше PING_SECONDS → кадр ping (keep-alive для прокси).""" + + async def scenario() -> str: + gen = events_api._event_source(_FakeRequest(), "u1") # type: ignore[arg-type] + await _anext_timeout(gen) # ready + # подменить таймаут на минимум, чтобы не ждать 20 секунд + saved = events_api.PING_SECONDS + events_api.PING_SECONDS = 0.01 + try: + return await _anext_timeout(gen) + finally: + events_api.PING_SECONDS = saved + await gen.aclose() + + frame = asyncio.run(scenario()) + assert '"ping"' in frame + + +# --- HTTP-публикации (перехват publish: TestClient стрим не читает) --- + + +@pytest.fixture +def published(monkeypatch: pytest.MonkeyPatch) -> list[Event]: + """Перехват publish в модуле api.tasks: события собираются в список.""" + seen: list[Event] = [] + import app.api.tasks as tasks_api + + def _spy(user_id: str | None, kind: str, data: dict[str, Any] | None = None) -> None: + seen.append(Event(kind=kind, data=data)) + publish(user_id, kind, data) + + monkeypatch.setattr(tasks_api, "publish", _spy) + return seen + + +def test_create_task_publishes(client: Any, published: list[Event]) -> None: + """Создание задачи → task.changed + xp.changed (без конфетти).""" + created = client.post("/api/tasks", json={"title": "Тест SSE"}) + assert created.status_code == 200 + kinds = [e.kind for e in published] + assert "task.changed" in kinds + assert "xp.changed" in kinds + xp = next(e for e in published if e.kind == "xp.changed") + assert xp.data == {"celebrate": False} + + +def test_close_task_publishes_xp(client: Any, published: list[Event]) -> None: + """Закрытие задачи → task.changed + xp.changed {celebrate: True} + garden.changed.""" + task_id = client.post("/api/tasks", json={"title": "Закрыть"}).json()["id"] + published.clear() + approved = client.post(f"/api/tasks/{task_id}/approve") + assert approved.status_code == 200 + published.clear() + closed = client.patch(f"/api/tasks/{task_id}", json={"status": "done"}) + assert closed.status_code == 200 + kinds = [e.kind for e in published] + assert "task.changed" in kinds + assert "xp.changed" in kinds + assert "garden.changed" in kinds + xp = next(e for e in published if e.kind == "xp.changed") + assert xp.data is not None and xp.data["celebrate"] is True + + +def test_delete_task_publishes_deleted(client: Any, published: list[Event]) -> None: + """Удаление задачи → task.deleted {id}.""" + task_id = client.post("/api/tasks", json={"title": "Удалить"}).json()["id"] + published.clear() + deleted = client.delete(f"/api/tasks/{task_id}") + assert deleted.status_code == 200 + assert [e for e in published if e.kind == "task.deleted" and e.data == {"id": task_id}] diff --git a/backend/tests/test_garden.py b/backend/tests/test_garden.py index 0e8f91d..49d098e 100644 --- a/backend/tests/test_garden.py +++ b/backend/tests/test_garden.py @@ -44,7 +44,8 @@ assert earned2["xp"] == "" and earned2["coins"] == "" state = client.get("/api/garden").json() - assert state["balance"] == coins_for_xp(int(earned["xp"])) + # баланс = монеты за закрытие + микронаграда за создание задачи (+1) + assert state["balance"] == coins_for_xp(int(earned["xp"])) + 1 def test_level_bonus_coins(client: TestClient) -> None: @@ -52,8 +53,8 @@ for _ in range(3): task_id = _create_task(client, priority=9, estimated_minutes=480) _done(client, task_id) - # Монеты: 3 × 20 за задачи + бонус 25×2 = 110 - assert client.get("/api/garden").json()["balance"] == 110 + # Монеты: 3 × 20 за задачи + 3 × 1 за создание + бонус 25×2 = 113 + assert client.get("/api/garden").json()["balance"] == 113 # --- Перемещение --- @@ -87,19 +88,19 @@ plant = next(i for i in client.get("/api/garden").json()["items"] if i["kind"] == "plant") assert plant["rarity"] == "common" - # Первая стадия ровно по карману (20 = 20) + # Первая стадия ровно по карману (20 = 20, +1 за создание задачи) resp = client.post(f"/api/garden/plants/{plant['id']}/upgrade") assert resp.status_code == 200 assert resp.json()["stage"] == 1 - assert resp.json()["balance"] == 0 + assert resp.json()["balance"] == 1 - # Вторая стадия — не хватает (50 > 0) + # Вторая стадия — не хватает (50 > 1) resp = client.post(f"/api/garden/plants/{plant['id']}/upgrade") assert resp.status_code == 402 assert "50" in resp.json()["detail"] for _ in range(10): - _done(client, _create_task(client)) # 10×5 монет + бонус L2 — с запасом + _done(client, _create_task(client)) # 10×5 монет + создания + бонус L2 — с запасом resp = client.post(f"/api/garden/plants/{plant['id']}/upgrade") assert resp.status_code == 200 assert resp.json()["stage"] == 2 @@ -119,7 +120,8 @@ resp = client.post("/api/garden/shop/buy", json={"item_key": "fence"}) assert resp.status_code == 200 balance_after = resp.json()["balance"] - assert balance_after == 40 - DECORATIONS["fence"]["cost"] + # 2 закрытия по 20 + 2 создания по 1 − изгородь + assert balance_after == 40 + 2 - DECORATIONS["fence"]["cost"] # Покупка кладёт декорацию в инвентарь: элемент есть, координат нет item = next( i @@ -219,4 +221,10 @@ def test_level_bonus_helper() -> None: assert level_bonus_coins(2) == 50 assert coins_for_xp(40) == 20 - assert set(EXPANSIONS) == {"expansion_1", "expansion_2", "expansion_3"} + assert set(EXPANSIONS) == { + "expansion_1", + "expansion_2", + "expansion_3", + "expansion_4", + "expansion_5", + } diff --git a/backend/tests/test_tasks_api.py b/backend/tests/test_tasks_api.py index 8d9565b..d79ecbb 100644 --- a/backend/tests/test_tasks_api.py +++ b/backend/tests/test_tasks_api.py @@ -90,6 +90,51 @@ assert bad.status_code == 422 +def test_project_archive_lifecycle(client: TestClient) -> None: + """Архив (ТЗ 3.11): проект уходит в архив с задачами и возвращается.""" + pid = client.post("/api/projects", json={"name": "ремонт"}).json()["id"] + task_id = create_task(client, "поклеить обои")["id"] + client.patch(f"/api/tasks/{task_id}", json={"project_id": pid}) + standalone = create_task(client, "без проекта") + + # в архиве пусто, в активных проект и задача видны + assert client.get("/api/projects", params={"archived": "true"}).json() == [] + assert any(p["id"] == pid for p in client.get("/api/projects").json()) + assert any(t["id"] == task_id for t in client.get("/api/tasks").json()) + + # архивируем: проект скрыт, задача с ним уходит из рабочих видов + archived = client.post(f"/api/projects/{pid}/archive") + assert archived.status_code == 200 + assert archived.json()["is_archived"] is True + assert client.get("/api/projects").json() == [] + archived_list = client.get("/api/projects", params={"archived": "true"}).json() + assert [p["id"] for p in archived_list] == [pid] + + tasks = client.get("/api/tasks").json() + assert all(t["id"] != task_id for t in tasks) + assert any(t["id"] == standalone["id"] for t in tasks) + # но на странице проекта (фильтр по project_id) история видна + assert any( + t["id"] == task_id + for t in client.get("/api/tasks", params={"project_id": pid}).json() + ) + # и в выдаче задачи архивные проекты не участвуют + options = client.post("/api/tasks/suggest", json={"available_minutes": 600}).json() + assert all(t["id"] != task_id for t in options) + + # восстановление возвращает всё как было + restored = client.post(f"/api/projects/{pid}/restore") + assert restored.status_code == 200 + assert restored.json()["is_archived"] is False + assert any(t["id"] == task_id for t in client.get("/api/tasks").json()) + + +def test_archive_invalid_relevance_rejected(client: TestClient) -> None: + pid = client.post("/api/projects", json={"name": "устаревший статус"}).json()["id"] + resp = client.patch(f"/api/projects/{pid}", json={"relevance_status": "archived"}) + assert resp.status_code == 422 + + def test_tag_duplicate(client: TestClient) -> None: assert client.post("/api/tags", json={"name": "быт"}).status_code == 200 assert client.post("/api/tags", json={"name": "быт"}).status_code == 409 diff --git a/backend/tests/test_xp.py b/backend/tests/test_xp.py index 0edceff..8c70b3b 100644 --- a/backend/tests/test_xp.py +++ b/backend/tests/test_xp.py @@ -17,14 +17,19 @@ def test_xp_granted_on_done(client: TestClient) -> None: - task_id = _create_task(client) + # Создание задачи — микронаграда (заголовки X-Created-*) + resp_create = client.post("/api/tasks", json={"title": "Задача для XP"}) + assert resp_create.headers.get("X-Created-XP") == "2" + assert resp_create.headers.get("X-Created-Coins") == "1" + task_id = resp_create.json()["id"] 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 + # XP: 2 за создание + 10 за закрытие; счётчик закрытий — только kind='task' + assert summary["total_xp"] == 12 assert summary["level"] == 1 assert summary["completed_count"] == 1 @@ -46,19 +51,20 @@ 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 + assert client.get("/api/xp").json()["total_xp"] == 12 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 + assert client.get("/api/xp").json()["total_xp"] == 12 def test_level_progression(client: TestClient) -> None: - # 9 закрытий по 10 XP = 90 — уровень 1; 10 закрытий = 100 — уровень 2 - for _ in range(9): + # каждая задача: 2 XP за создание + 10 за закрытие = 12 + # 8 закрытий = 96 — уровень 1; 9-е выводит за 100 — уровень 2 + for _ in range(8): task_id = _create_task(client) client.patch(f"/api/tasks/{task_id}", json={"status": "done"}) assert client.get("/api/xp").json()["level"] == 1 @@ -66,9 +72,9 @@ 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_xp"] == 8 assert summary["level_span"] == 200 - assert summary["level_percent"] == 0 + assert summary["level_percent"] == 4 def test_compute_rarity_boundaries() -> None: @@ -86,7 +92,9 @@ assert compute_rarity(40, 0.99) == "common" -def test_xp_granted_with_rarity_and_options(client: TestClient) -> None: +def test_xp_granted_with_rarity_and_options(client: TestClient, monkeypatch: Any) -> None: + # редкость случайна — фиксируем roll, чтобы тест не мигал (rare при xp=10 — 5%) + monkeypatch.setattr("app.services.xp.random.random", lambda: 0.99) task_id = _create_task(client) resp = client.patch( f"/api/tasks/{task_id}", json={"status": "done", "earned_via": "options"} @@ -97,11 +105,13 @@ assert resp.headers.get("X-Options-Close") == "1" events = client.get("/api/xp/events").json() - assert len(events) == 1 - assert events[0]["task_id"] == task_id - assert events[0]["amount"] == 10 - assert events[0]["rarity"] == "common" - assert events[0]["via_options"] is True + assert len(events) == 2 # событие создания + событие закрытия + close_event = next(e for e in events if e["task_id"] == task_id) + assert close_event["amount"] == 10 + assert close_event["rarity"] == "common" + assert close_event["via_options"] is True + created_event = next(e for e in events if e["task_id"] is None) + assert created_event["kind"] == "create_task" # закрытие из обычного списка — via_options не ставится task2 = _create_task(client) @@ -142,3 +152,50 @@ resp = client.patch(f"/api/tasks/{task_id}", json={"status": "done"}) assert resp.headers.get("X-Earned-XP") == "10" assert "X-Mentally-Hard" not in resp.headers + + +# --- Микронаграды за создание (ТЗ 3.13) --- + + +def test_project_creation_reward(client: TestClient) -> None: + resp = client.post("/api/projects", json={"name": "Проект награды"}) + assert resp.status_code == 200 + assert resp.headers.get("X-Created-XP") == "5" + assert resp.headers.get("X-Created-Coins") == "2" + + # XP проекта в суммарный опыт входит, счётчик закрытий — нет + summary = client.get("/api/xp").json() + assert summary["total_xp"] == 5 + assert summary["completed_count"] == 0 + events = client.get("/api/xp/events").json() + assert [e["kind"] for e in events] == ["create_project"] + + # Сад: событие создания не рождает растение + state = client.get("/api/garden").json() + assert state["balance"] == 2 + assert not [i for i in state["items"] if i["kind"] == "plant"] + + +def test_task_creation_reward_counts_in_xp_but_not_plants(client: TestClient) -> None: + client.post("/api/tasks", json={"title": "Только создание"}) + summary = client.get("/api/xp").json() + assert summary["total_xp"] == 2 + assert summary["completed_count"] == 0 + + state = client.get("/api/garden").json() + assert state["balance"] == 1 + assert not [i for i in state["items"] if i["kind"] == "plant"] + + +# --- Ежедневная награда (ТЗ 3.13) --- + + +def test_daily_reward_once_per_day(client: TestClient) -> None: + first = client.post("/api/xp/daily") + assert first.status_code == 200 + assert first.json() == {"granted": True, "coins": 50} + + # Повторный визит в те же сутки — молча, без начисления + second = client.post("/api/xp/daily") + assert second.json() == {"granted": False, "coins": 0} + assert client.get("/api/garden").json()["balance"] == 50 diff --git a/docs/TZ.md b/docs/TZ.md index 7bc0214..732a859 100644 --- a/docs/TZ.md +++ b/docs/TZ.md @@ -4,8 +4,8 @@ | | | |---|---| -| Версия ТЗ | 0.16 | -| Дата | 2026-09-20 | +| Версия ТЗ | 0.24 | +| Дата | 2026-09-21 | | Статус | На обсуждении | --- @@ -107,6 +107,10 @@ ### 3.6. Представления задач - **Классический вид**: список с фильтрами (проект, тег, статус, приоритет). +- **Пагинация**: длинные списки (список задач, стек на главной, задачи проекта, + проекты, архив) разбиваются на страницы **по 30 элементов**; навигация кнопками + (‹ номера ›, с усечением «…» и подписью «Страница N из M»). Смена фильтров + возвращает на первую страницу; при одной странице контрол не показывается. - Подзадачи показываются карточками внутри задачи и в списке; представление «дерево» убрано (проба 0.5 показала, что оно мешает — возможен возврат позже). Связь подзадачи с родителем (`parent_task_id`) в модели данных сохраняется. @@ -121,17 +125,34 @@ ### 3.8. Проекты -- Статус **актуальности** проекта (активен / приостановлен / закрыт). +- Статус **актуальности** проекта (активен / приостановлен; «закрыт» выражается архивом, см. 3.8.1). - **Приоритет** проекта. - **Заметка к проекту**: Markdown-поле для ссылок на ресурсы проекта и прочего контекста; заметка — тот же «документ», что и описание задачи: в неё можно вставлять изображения из буфера (Ctrl+V). -### 3.9. Режим выбора («3 варианта») +#### 3.8.1. Архив -Анти-прокрастинационный режим: пользователь указывает доступное время (например, «3 часа»), система предлагает **3 варианта задач**, подходящих под это время; пользователь выбирает один и выполняет сразу. +Проект, с которым пользователь закончил работу, **отправляется в архив** — один архив +на все проекты, отдельных архивов задач нет. + +- Архивация — с подтверждением («Отправить проект и его N задач в архив?»). +- Архивный проект пропадает из рабочих видов: списка проектов, фильтров, выдачи + задачи (3.9); его задачи не видны в активных списках задач. **Задачи живут и + уезжают в архив только вместе с проектом**: отдельной архивации/удаления + для задач внутри проекта нет (удаление отдельной задачи остаётся — гигиена). +- Статистика и XP не пересчитываются: закрытия в архивных проектах остаются в истории. +- **Страница «Архив»** (отдельный раздел меню): история архивных проектов — + открытие проекта, все задачи, заметка. Восстановление возвращает проект + со всеми задачами и статусами как были. Доступно и «удалить навсегда». +- У архивного проекта страница доступна на чтение (с плашкой «в архиве»); + создание задач и правки спрятаны. + +### 3.9. Выдача задачи (бывший режим «3 вариантов») + +Анти-прокрастинационный режим: пользователь указывает доступное время в часах (например, «3», можно 0.5), система предлагает подходящие задачи; предложение — те же карточки, что в списке задач. - Выбор кандидатов учитывает: прогнозируемую длительность ≤ доступного времени, приоритеты, статус актуальности проектов. -- Варианты должны быть осмысленно разными (не три дубликата). -- После выбора — прямой переход к выполнению задачи (отметка начала, быстрый доступ к описанию). +- Варианты должны быть осмысленно разными (не дубликаты). +- Отдельной кнопки выбора/выполнения на странице выдачи нет: пользователь открывает карточку задачи (переход несёт `?via=options`) и берёт её в работу на странице задачи; закрытие задачи, открытой из выдачи, помечается «выбрал и сделал». ### 3.10. MCP-интерфейс @@ -170,10 +191,24 @@ - **XP за закрытие задачи**: база 10 + бонус за приоритет (0–15) + бонус за объём по оценке времени (0–15). Начисление — один раз на задачу (идемпотентно); - удаление задачи не отнимает заработанное. Хранение — таблица `xp_events`. + удаление задачи не отнимает заработанное. Хранение — таблица `xp_events` + (поле `kind` разделяет события: `task` — закрытие, `create_task` / + `create_project` — создание). +- **Микронаграды за создание** (без конфетти — скромный тост «+N XP • +M монет»): + создание задачи — +2 XP и +1 монета, создание проекта — +5 XP и +2 монеты. + В суммарный опыт (уровень) входят, растений в саду не рождают, счётчик + закрытых задач не увеличивают; хранятся с `task_id = NULL` — удаление задачи + или проекта награду не отнимает. +- **Ежедневная награда за визит**: при открытии приложения фронт запрашивает + `POST /api/xp/daily` — раз в сутки (граница дня UTC) начисляется **50 монет** + (только монеты, XP нет), повторные визиты в те же сутки молча пропускаются. + Тост «+50 монет» только в день первого визита. - **Уровень**: растёт из суммарного XP (уровень не падает); прогресс до следующего уровня и **название звания** показываются в «Саду». Звания ботанические - (Росток, Сеянцев, Садовник, … Легенда сада — 10 званий). + (Росток, Сеянцев, Садовник, … Легенда сада — 13 званий; порог уровня + L = 100·L·(L−1)/2 XP). **Дерево уровней** — модалка со списком всех уровней: + звание, порог XP, что открывается (виды растений с ценой семени и + premium-пометкой, расширения карты); пройденные отмечены, текущий подсвечен. - **Момент награды**: при закрытии задачи — короткая конфетти-анимация и тост «+N XP» с похвалой. Особые закрытия (редкое растение, крупная задача) — усиленное «золотое» конфетти. @@ -187,7 +222,7 @@ - **Отрисовка сцены** — пиксель-арт на **PixiJS 8** (WebGL): `nearest` масштабирование без сглаживания, целочисленный «нативный пиксель» (матрицы 16px × 2 = 32px на клетку). Все спрайты (тайлы травы/дикой земли, изгородь, - домик, 6 видов растений × 3 стадии, декорации, кольца редкости, блёстки) + домик, 13 видов растений × 3 стадии, декорации, кольца редкости, блёстки) генерируются из пиксель-матриц прямо в коде — внешних файлов-ассетов нет. Рендерер (`src/game/GardenRenderer.ts`) — обычный класс, **не** оборачивается во Vue-reactive (deep-прокси ломает внутренние identity-проверки Pixi); @@ -211,17 +246,38 @@ момент рождения** (premium-виды — только для rare/epic закрытий), и фиксируется за растением (`garden_items.item_key`) — открытые позже виды не перерисовывают старые растения. Растение привязано к событию XP (переживает - удаление задачи). Каталог видов — в маркете сада с замками «откроется на - уровне N». + удаление задачи). +- **Семена растений** (покупка видов за монеты): в маркете блок **«Семена + растений»** — открытые уровнем виды продаются семенами (цены 30–1200 по + «дороговизне» вида; 13 видов, открытие растянуто по уровням 1–13 — + прогрессия рассчитана примерно на 300 закрытых задач при среднем + ~26 XP за закрытие). Покупка кладёт растение-росток в **инвентарь** (как + декорации); перетаскивание из инвентаря на свободную клетку сажает его. + Размещённое семя обратно в инвентарь вернуть нельзя (сад только растёт); + редкость — common, задача-родитель отсутствует. Доступность семени — по + уровню вида (🔒 «откроется на уровне N»), независимо от premium-флага вида + (вопрос о premium-видах в семенах — открыт, см. раздел 8). - **Рост растений за монеты**: растение растёт стадиями росток → куст → цветение, каждая стадия покупается монетами (цена по редкости: common 20/50, rare 40/100, epic 80/200). Автороста по времени нет. - **Маркет сада**: в модалке — **расширения карты** (первым блоком), - **декорации** и **каталог видов**. У каждой покупки три состояния: 🔒 - «откроется на уровне N» (если уровень не открывает), цена золотом (кнопка - активна), цена серым + «не хватает N монет». - - **Декорации** — чисто косметика: фонарь, лавочка, поилка, пруд, беседка - (уникальные), изгородь и клумба (повторяемые). Двигаются как растения. + **декорации**, **семена растений** и каталог видов (справка о редкости). + У каждой покупки три состояния: 🔒 «откроется на уровне N» (если уровень + не открывает), цена золотом (кнопка активна), цена серым + «не хватает + N монет». + - **Декорации** — чисто косметика: фонарь, лавочка, поилка, пруд, беседка, + флюгер, скворечник, садовый самоцвет, теплица (уникальные), изгородь, + клумба и куст (повторяемые). Двигаются как растения. **Изгородь — + самосоединяющиеся секции**: текстура выбирается по маске соседей (N/E/S/W), + секция соединяется только с соседними секциями изгороди. **Постройки + в изометрии (домик, теплица, беседка) — крупная изометрия 2:1** (домик + с двором, теплица со стеклянными стенами и каркасом, беседка с площадкой + у входа). Дорожки убраны (не прижились — сложность рендера и соединений). + - **Подпись выбранного объекта**: клик по любому объекту сада (растение + или декорация) показывает у клетки чип с иконкой и названием + («Скворечник», «Куст», «Подсолнух»); у растений одновременно открывается + поповер роста. Повторный клик по объекту или клик по пустому месту + снимает выбор. - **Инвентарь декораций**: покупка кладёт декорацию в **инвентарь** под картой (`garden_items.x/y = NULL`) — место покупки всегда видно пользователю. Из инвентаря декорация перетаскивается на карту; перетаскивание размещённой @@ -229,12 +285,13 @@ инвентарь вернуть нельзя — сад только растёт). - **Расширения карты**: стартовая сетка 32×20 ячеек; каждое расширение (+8 клеток периметра) открывается уровнем (I — ур. 3, II — ур. 5, - III — ур. 8) и покупается монетами (100/250/500). Финал ~56×44. + III — ур. 8, IV — ур. 9, V — ур. 11) и покупается монетами + (100/250/500/750/1000). Финал ~72×60. - **Ачивки с тирами**: у каждой ачивки ступени I/II/III (например, 10/50/100 задач), прогресс к следующей ступени виден всегда. Набор: вехи закрытий, уровни, макс. задач за день, разные проекты, суммарные часы, строгие - дедлайны, закрытия через режим «3 вариантов» (см. 3.9), регулярная, проект. -- **Связка с «3 вариантами»** (3.9): закрытие задачи из режима помечается + дедлайны, закрытия через выдачу задачи (см. 3.9), регулярная, проект. +- **Связка с выдачей задачи** (3.9): закрытие задачи, открытой из выдачи, помечается (`earned_via`) и празднуется меткой «выбрал и сделал» + счётчик-ачивкой; XP при этом не меняется. - **Страница «Статистика»**: закрываемость за выбранный **день / неделю / месяц** @@ -255,6 +312,41 @@ - **Справка «О саде»**: кнопка на странице сада открывает модалку с объяснением механик (XP, уровни, растения и редкости, ачивки, принцип «только позитив»). +### 3.14. Реактивность: серверные события (SSE) + +Данные меняются не только из UI: MCP-агенты (3.10) и фоновая автодетализация (3.2) +пишут в БД вне HTTP-запроса. Все виды обновляются событиями от сервера — без +обновления страницы и переходов между страницами. + +- **Эндпоинт**: `GET /api/events` (авторизация штатная, 401 — на вход). + Транспорт — **SSE** (`text/event-stream`), по одному соединению на вкладку + (`EventSource`, авто-reconnect браузером). Keep-alive: при тишине дольше 20 с + сервер шлёт кадр `ping`, чтобы прокси (Vite dev, nginx) не рвал поток. +- **Модель — грубозернистые события**: событие это `kind` + опциональные данные; + фронт реагирует **refetch'ем** (перечитывает данные целиком), а не патчами. + Трейлинг-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 по сцене); страница задачи — + перечитывается при `task.changed`/`detail.changed` своего `id`. +- **Точки публикации** (строго после коммита транзакции): 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` покрывает. +- **Дедуп XP-тостов**: действие своей вкладки празднуется по заголовкам ответа + (`X-Earned-XP`, ТЗ 3.13); SSE-эхо и эхо соседней вкладки молчат — окно + активности 3 с (localStorage). Соседние вкладки показывают скромный тост + «+N XP» без конфетти. +- **Reconnect**: при обрыве — backoff 1→2→5→15 с; 401 при проверке сессии — + редирект на вход; после переподключения — форс-рефреш всех открытых видов + (данные за время разрыва могли измениться). + + ## 4. Нефункциональные требования | Требование | Значение | @@ -269,6 +361,7 @@ | Упаковка | В итоге всё пакуется в **Docker** (docker-compose: API, PostgreSQL, фронт, Ollama + модель) | | Мобильность | На старте — **только PWA**; Android-приложение — вне скоупа стартовой версии (вернуться к нему позже) | | Приватность | Все данные и LLM-вызовы — на собственном сервере | +| Реактивность | SSE (`/api/events`): грубозернистые события + refetch, см. 3.14 | Внешние зависимости проекта (не форкать, обновлять через пакетный менеджер): @@ -300,7 +393,9 @@ Project id, name - relevance_status active | paused | archived — статус актуальности + relevance_status active | paused — статус актуальности + is_archived bool — архив (3.8.1): проект и его задачи скрыты + из рабочих видов, восстановимы из «Архива» priority note markdown — живёт в Document (owner_type = project): ссылки, контекст, картинки @@ -334,17 +429,19 @@ ``` ┌─────────────┐ ┌────────────────────── VPS (docker-compose) ─────────────┐ │ Web SPA │────▶│ API (FastAPI) ──▶ PostgreSQL │ -│ Vue 3 + │ │ │ │ -│ gnexus- │ │ ├──▶ Сервис автодетализации ──▶ Ollama (модель │ -│ ui-kit │ │ │ — LLM, модель из конфига) │ -└─────────────┘ │ ├──▶ Хранилище вложений (файлы) │ - │ └──▶ MCP-сервер (для ИИ-агентов) │ +│ Vue 3 + │ │ ▲ │ │ +│ gnexus- │ │ │ ├──▶ Сервис автодетализации ──▶ Ollama (модель │ +│ ui-kit │ │ │ │ — LLM, модель из конфига) │ +└─────────────┘ │ │ ├──▶ Хранилище вложений (файлы) │ + ▲ │ │ └──▶ MCP-сервер (для ИИ-агентов) │ + └─ SSE ───────┤ шина событий (realtime) │ └─────────────────────────────────────────────────────────┘ ▲ SSO auth.gnexus.space (клиент gnexus-gauth) ``` - API и MCP-сервер — одно Python-приложение (общая бизнес-логика). - Вложения — файлы на диске VPS + метаданные в БД. +- Изменения данных публикуются в шину событий и уходят в SPA по SSE (3.14). ## 7. Этапы разработки (предложение) @@ -354,7 +451,7 @@ | M1 — MVP задач | CRUD задач, быстрый ввод, стек входящих, теги, проекты (базово), ручная детализация | | M2 — детализация | Интеграция Ollama + модель из конфига, автопредзаполнение метаданных, подтверждение/редактирование, Markdown-редактор, вложения из буфера | | M3 — структура | Подзадачи (parent_task_id), классический список с фильтрами, заметки проектов | -| M4 — умность | Прогнозирование времени, бюджет, режим «3 варианта» | +| M4 — умность | Прогнозирование времени, бюджет, выдача задачи | | M5 — MCP | MCP-сервер, инструменты для агентов | | M6 — мобильность | PWA (Android-приложение — позже, вне стартового скоупа) | | M7 — упаковка | Docker (docker-compose: API, PostgreSQL, фронт, Ollama + модель) | @@ -370,4 +467,6 @@ 5. **Модель для Ollama**: какая конкретно модель и параметры (в конфиг, но нужно выбрать для проверки M2). 6. **Практическое различие `cancelled` / `deferred`**: определения зафиксированы (3.4), но поведение в интерфейсе почти одинаковое — скрыть из активных списков и оставить доступным для возврата. Уточнить при проектировании представлений, нужны ли оба статуса или их поведение сольётся. 7. **Анти-дюп награды за статус**: убедиться, что перевод задачи в «завершена» и обратно (и повторное закрытие) **не** начисляет XP/монеты/растение повторно — идемпотентность начисления при любых переключениях статуса (лазейка для дюпа награды). Проверить и покрыть тестом. -8. ~~**Пиксель-арт сцена сада**~~ — **решено 2026-09-20**: сцена переведена на пиксель-арт (PixiJS 8, WebGL); спрайты генерируются из пиксель-матриц в коде (без внешних ассетов и лицензий), см. 3.13. \ No newline at end of file +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 diff --git a/frontend/src/App.vue b/frontend/src/App.vue index b449560..d20e285 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -4,6 +4,7 @@ import { useRoute } from 'vue-router' import { api } from './api' import { resolveLocale } from './i18n/locale' +import { startRealtime } from './realtime' import { setCurrency } from './taskui' import GamifyBridge from './components/GamifyBridge.vue' import type { UserInfo } from './types' @@ -14,11 +15,13 @@ 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: 'stats', label: t('nav.stats'), icon: 'ph-chart-bar', to: '/stats' }, + { 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: 'options', label: t('nav.options'), icon: 'ph-shuffle', to: '/options' }, + { id: 'garden', label: t('nav.garden'), icon: 'ph-leaf', to: '/garden' }, + { id: 'stats', label: t('nav.stats'), icon: 'ph-chart-bar', to: '/stats' }, + // архив — редко используемый раздел, предпоследний пункт + { id: 'archive', label: t('nav.archive'), icon: 'ph-archive', to: '/archive' }, { id: 'settings', label: t('nav.settings'), icon: 'ph-gear-six', to: '/settings' }, ]) @@ -28,6 +31,7 @@ // глубокие страницы: задача → страница задачи, проект → раздел проектов if (route.path.startsWith('/tasks/')) return t('nav.task') if (route.path.startsWith('/projects/')) return t('nav.projects') + if (route.path.startsWith('/archive')) return t('nav.archive') return 'GNexus Tasks' }) @@ -37,6 +41,9 @@ const data = await res.json() user.value = data.user accountUrl.value = data.account_url ?? '' + // SSE-события (ТЗ 3.14): только для авторизованных — иначе EventSource + // будет бесконечно ловить 401 + startRealtime() } // валюта и язык — глобальные настройки; язык без переопределения берётся из SSO try { @@ -139,11 +146,16 @@ .app-content .page-header::after { display: none; } -/* меню в шапке прижимается к правому краю кнопки (left:0 кита вылетает за вьюпорт) */ +/* меню в шапке прижимается к правому краю кнопки (left:0 кита вылетает за вьюпорт); + общий в пределах экрана фикс — installDropdownClamp (JS) в main.ts */ .page-header .dropdown.is-open .dropdown-menu { left: auto; right: 0; } +/* страховка: меню не шире экрана (длинные пункты переносятся) */ +.dropdown-menu { + max-width: calc(100vw - 16px); +} /* иконки в заголовках карточек — тёмные, не выпячиваются */ .app-content .card .card-title-icon { color: #3b4261; @@ -170,7 +182,6 @@ top: -16px; width: 9px; height: 14px; - border-radius: 2px; animation-name: confetti-fall; animation-timing-function: ease-in; animation-fill-mode: forwards; diff --git a/frontend/src/api.ts b/frontend/src/api.ts index dd89a9a..07d6280 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -1,5 +1,6 @@ // Мини-клиент API. Ошибки авторизации редиректят на вход. import i18n from './i18n' +import { markOwnActivity } from './realtime' export interface Tag { id: number @@ -10,6 +11,7 @@ id: number name: string relevance_status: string + is_archived: boolean priority: number | null note: string document_id: number | null @@ -98,6 +100,7 @@ export interface XpEvent { task_id: number | null + kind: 'task' | 'create_task' | 'create_project' amount: number rarity: string via_options: boolean @@ -132,6 +135,7 @@ level: number premium: boolean unlocked: boolean + seed_cost: number } export interface GardenExpansion { @@ -147,6 +151,7 @@ items: GardenItem[] decorations: { key: string; cost: number; unique: boolean }[] next_expansion: GardenExpansion | null + expansions: GardenExpansion[] // все расширения — для дерева уровней expansions_bought: number species: GardenSpecies[] upgrade_costs: Record @@ -174,9 +179,11 @@ throw new ApiError(res.status, body.detail ?? i18n.global.t('common.error')) } // Геймификация: бэк помечает закрытие задачи заголовками (XP, редкость, «3 варианта», - // «ментально сложная») + // «ментально сложная»). Заголовки = собственное действие: отметка для дедупа + // SSE-эха (realtime.ts, окно 3 с) const earned = res.headers.get('X-Earned-XP') if (earned) { + markOwnActivity() earnedXpBuffer = { xp: Number(earned) || 0, coins: Number(res.headers.get('X-Earned-Coins')) || 0, @@ -185,6 +192,15 @@ hard: res.headers.get('X-Mentally-Hard') === '1', } } + // Микронаграда за создание (задача/проект): без конфетти, просто тост + const createdXp = res.headers.get('X-Created-XP') + if (createdXp) { + markOwnActivity() + createdRewardBuffer = { + xp: Number(createdXp) || 0, + coins: Number(res.headers.get('X-Created-Coins')) || 0, + } + } return res.json() as Promise } @@ -205,6 +221,20 @@ return value } +// Микронаграда за создание (задача/проект): забирается через takeCreatedReward +export interface CreatedReward { + xp: number + coins: number +} + +let createdRewardBuffer: CreatedReward | null = null + +export function takeCreatedReward(): CreatedReward | null { + const value = createdRewardBuffer + createdRewardBuffer = null + return value +} + export const api = { // tasks listTasks: (params: Record = {}) => { @@ -254,13 +284,18 @@ request<{ ok: boolean }>(`/api/attachments/${id}`, { method: 'DELETE' }), // Ссылка на файл вложения (нужна сессионная cookie — просто ) attachmentUrl: (id: number) => `/api/attachments/${id}/file`, - // projects - listProjects: () => request('/api/projects'), + // projects (архив — ТЗ 3.11: archived=true отдаёт историю) + listProjects: (archived = false) => + request(`/api/projects?archived=${archived}`), getProject: (id: number) => request(`/api/projects/${id}`), createProject: (name: string) => request('/api/projects', { method: 'POST', body: JSON.stringify({ name }) }), updateProject: (id: number, patch: Partial>) => request(`/api/projects/${id}`, { method: 'PATCH', body: JSON.stringify(patch) }), + archiveProject: (id: number) => + request(`/api/projects/${id}/archive`, { method: 'POST' }), + restoreProject: (id: number) => + request(`/api/projects/${id}/restore`, { method: 'POST' }), deleteProject: (id: number) => request<{ ok: boolean }>(`/api/projects/${id}`, { method: 'DELETE' }), // tags @@ -274,6 +309,9 @@ // геймификация (ТЗ 3.13) getXp: () => request('/api/xp'), getXpEvents: () => request('/api/xp/events'), + // дейли-награда за визит (раз в сутки, идемпотентно) + claimDaily: () => + request<{ granted: boolean; coins: number }>('/api/xp/daily', { method: 'POST' }), // сад (ТЗ 3.13) getGarden: () => request('/api/garden'), moveGardenItem: (id: number, x: number | null, y: number | null) => diff --git a/frontend/src/components/ConfirmDialog.vue b/frontend/src/components/ConfirmDialog.vue new file mode 100644 index 0000000..f7fbd3f --- /dev/null +++ b/frontend/src/components/ConfirmDialog.vue @@ -0,0 +1,47 @@ + + + \ No newline at end of file diff --git a/frontend/src/components/Dashboard.vue b/frontend/src/components/Dashboard.vue index 7411719..1192706 100644 --- a/frontend/src/components/Dashboard.vue +++ b/frontend/src/components/Dashboard.vue @@ -9,9 +9,8 @@ import { deadlineLabel, deadlineOverdue, - priorityLabel, - priorityVariant, } from '../taskui' +import PriorityBadge from './PriorityBadge.vue' const props = defineProps<{ tasks: Task[] @@ -133,9 +132,7 @@
{{ task.title }}
- -