diff --git a/backend/alembic/versions/c8f7e6d5b4a3_task_mentally_hard.py b/backend/alembic/versions/c8f7e6d5b4a3_task_mentally_hard.py new file mode 100644 index 0000000..37ca135 --- /dev/null +++ b/backend/alembic/versions/c8f7e6d5b4a3_task_mentally_hard.py @@ -0,0 +1,28 @@ +"""Метка «ментально сложная» на задаче (ТЗ 3.13) — кнопка «не хочется делать». + +Revision ID: c8f7e6d5b4a3 +Revises: b7e4d5c6a8f9 +Create Date: 2026-09-20 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +revision: str = "c8f7e6d5b4a3" +down_revision: str | Sequence[str] | None = "b7e4d5c6a8f9" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.add_column( + "tasks", + sa.Column("mentally_hard", sa.Boolean(), nullable=False, server_default=sa.false()), + ) + + +def downgrade() -> None: + op.drop_column("tasks", "mentally_hard") diff --git a/backend/app/api/tasks.py b/backend/app/api/tasks.py index 3491377..e2e8f78 100644 --- a/backend/app/api/tasks.py +++ b/backend/app/api/tasks.py @@ -153,6 +153,9 @@ response.headers["X-Plant-Rarity"] = event.rarity if event.via_options: response.headers["X-Options-Close"] = "1" + if task.mentally_hard: + # «Ментально сложная» — фронт украшает праздник («сила воли») + response.headers["X-Mentally-Hard"] = "1" db.flush() db.refresh(task) # перечитать связи (project/tags) после обновления diff --git a/backend/app/models.py b/backend/app/models.py index 63ac52a..e382981 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -42,6 +42,9 @@ # Плоскость детализации стека: raw (в стеке) / approved (утверждена) detail_state: Mapped[str] = mapped_column(String(20), default="raw") done: Mapped[bool] = mapped_column(Boolean, default=False) + # «Ментально сложная» (ТЗ 3.13): метка «не хочется делать» — трудность не по + # объёму, а из-за волевых усилий; даёт бонус к XP при закрытии + mentally_hard: Mapped[bool] = mapped_column(Boolean, default=False) parent_task_id: Mapped[int | None] = mapped_column( ForeignKey("tasks.id"), nullable=True, default=None diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 0c414c0..c8c245e 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -57,6 +57,8 @@ recur_interval_days: int | None = Field(None, ge=1, le=365) recur_weekdays: str | None = Field(None, pattern=r"^[1-7](,[1-7])*$") # ISO, пн=1 recur_day_of_month: int | None = Field(None, ge=1, le=31) + # «Ментально сложная» (ТЗ 3.13): метка «не хочется делать», +10 XP при закрытии + mentally_hard: bool | None = None # Не поле задачи: пометка источника закрытия для геймификации ("options" — режим «3 вариантов») earned_via: str | None = None @@ -85,6 +87,7 @@ recur_interval_days: int | None recur_weekdays: str | None recur_day_of_month: int | None + mentally_hard: bool created_at: datetime approved_at: datetime | None done_at: datetime | None diff --git a/backend/app/services/xp.py b/backend/app/services/xp.py index d8b3943..54ba3c5 100644 --- a/backend/app/services/xp.py +++ b/backend/app/services/xp.py @@ -19,6 +19,8 @@ # Бонус за оценку времени: больше работа — больше награда (минуты -> бонус, # больше 240 -> последний бонус) ESTIMATE_BONUS = ((15, 2), (60, 5), (240, 10), (float("inf"), 15)) +# Бонус за «ментально сложную» (метка «не хочется делать»): награда за силу воли +WILLPOWER_BONUS = 10 # Уровень L достигается при XP >= LEVEL_FACTOR * (L-1) * L / 2: # L2 = 100, L3 = 300, L4 = 600, L5 = 1000 ... на уровень L -> L+1 нужно 100*L @@ -40,8 +42,10 @@ def compute_task_xp(task: Task) -> int: - """XP за задачу: база + бонус приоритета + бонус объёма (оценки времени).""" + """XP за задачу: база + бонус приоритета + бонус объёма + бонус силы воли.""" xp = XP_BASE + PRIORITY_BONUS[priority_grade(task.priority)] + if task.mentally_hard: + xp += WILLPOWER_BONUS minutes = task.estimated_minutes if minutes is not None: for limit, bonus in ESTIMATE_BONUS: diff --git a/backend/tests/test_xp.py b/backend/tests/test_xp.py index 2f89537..0edceff 100644 --- a/backend/tests/test_xp.py +++ b/backend/tests/test_xp.py @@ -120,3 +120,25 @@ assert rarity in ("common", "rare", "epic") events = client.get("/api/xp/events").json() assert events[0]["rarity"] == rarity + + +def test_mentally_hard_bonus(client: TestClient) -> None: + # метка «ментально сложная» (ТЗ 3.13): +10 к XP при закрытии + task_id = _create_task(client, mentally_hard=True) + resp = client.patch(f"/api/tasks/{task_id}", json={"status": "done"}) + # 10 база + 10 за силу воли + assert resp.headers.get("X-Earned-XP") == "20" + assert resp.headers.get("X-Mentally-Hard") == "1" + + # флаг виден в выдаче задачи + assert client.get(f"/api/tasks/{task_id}").json()["mentally_hard"] is True + + +def test_mentally_hard_toggle_before_done(client: TestClient) -> None: + # переключатель: метку можно снять до закрытия — бонус пропадает + task_id = _create_task(client, mentally_hard=True) + resp = client.patch(f"/api/tasks/{task_id}", json={"mentally_hard": False}) + assert resp.json()["mentally_hard"] is False + 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 diff --git a/docs/TZ.md b/docs/TZ.md index 20718dd..442e22c 100644 --- a/docs/TZ.md +++ b/docs/TZ.md @@ -4,7 +4,7 @@ | | | |---|---| -| Версия ТЗ | 0.11 | +| Версия ТЗ | 0.12 | | Дата | 2026-09-20 | | Статус | На обсуждении | @@ -194,6 +194,12 @@ «как в гитхабе» за полгода или год (переключатель диапазона; цвет по закрытым задачам или XP, переключается). Данные считаются из `xp_events` и дат задач, без новых эндпоинтов. +- **Метка «ментально сложная»**: у незакрытой задачи есть кнопка-переключатель + **«Не хочется делать»** (warning-стиль) — она ставит метку «ментально сложная»: + трудность не по объёму, а из-за требуемых волевых усилий. Метку можно снять до + закрытия, после закрытия она не меняется. Закрытие такой задачи даёт **+10 XP** + (сила воли) и всегда празднуется золотым конфетти; ачивка **«Преодоление»** + (5/25/50 закрытых «ментально сложных») — в саду. - **Справка «О саде»**: кнопка на странице сада открывает модалку с объяснением механик (XP, уровни, растения и редкости, ачивки, принцип «только позитив»). diff --git a/frontend/src/api.ts b/frontend/src/api.ts index fa82cce..64a463a 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -54,6 +54,8 @@ recur_interval_days: number | null recur_weekdays: string | null recur_day_of_month: number | null + /** «Ментально сложная» (ТЗ 3.13): метка «не хочется делать», +10 XP при закрытии */ + mentally_hard: boolean created_at: string approved_at: string | null done_at: string | null @@ -78,6 +80,7 @@ recur_interval_days?: number | null recur_weekdays?: string | null recur_day_of_month?: number | null + mentally_hard?: boolean /** Не поле задачи: источник закрытия для геймификации ("options" — режим «3 вариантов») */ earned_via?: string } @@ -128,13 +131,15 @@ const body = await res.json().catch(() => ({ detail: res.statusText })) throw new ApiError(res.status, body.detail ?? i18n.global.t('common.error')) } - // Геймификация: бэк помечает закрытие задачи заголовками (XP, редкость, «3 варианта») + // Геймификация: бэк помечает закрытие задачи заголовками (XP, редкость, «3 варианта», + // «ментально сложная») const earned = res.headers.get('X-Earned-XP') if (earned) { earnedXpBuffer = { xp: Number(earned) || 0, rarity: res.headers.get('X-Plant-Rarity') ?? 'common', options: res.headers.get('X-Options-Close') === '1', + hard: res.headers.get('X-Mentally-Hard') === '1', } } return res.json() as Promise @@ -145,6 +150,7 @@ xp: number rarity: string // common | rare | epic options: boolean // закрыто из режима «3 вариантов» + hard: boolean // «ментально сложная» — награда за силу воли } let earnedXpBuffer: EarnedReward | null = null diff --git a/frontend/src/components/TaskBadges.vue b/frontend/src/components/TaskBadges.vue index a60d089..d957d7d 100644 --- a/frontend/src/components/TaskBadges.vue +++ b/frontend/src/components/TaskBadges.vue @@ -42,6 +42,9 @@ >