diff --git a/backend/alembic/versions/5245dee4bd6e_deadlines_and_recurrence.py b/backend/alembic/versions/5245dee4bd6e_deadlines_and_recurrence.py new file mode 100644 index 0000000..805eb64 --- /dev/null +++ b/backend/alembic/versions/5245dee4bd6e_deadlines_and_recurrence.py @@ -0,0 +1,42 @@ +"""deadlines and recurrence + +Revision ID: 5245dee4bd6e +Revises: 32ac7c6e19bb +Create Date: 2026-09-19 21:27:44.725077 + +""" +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = '5245dee4bd6e' +down_revision: str | Sequence[str] | None = '32ac7c6e19bb' +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('tasks', sa.Column('deadline_date', sa.Date(), nullable=True)) + op.add_column('tasks', sa.Column('deadline_period', sa.String(length=20), nullable=True)) + op.add_column('tasks', sa.Column('recur_kind', sa.String(length=20), nullable=True)) + op.add_column('tasks', sa.Column('recur_interval_days', sa.Integer(), nullable=True)) + op.add_column('tasks', sa.Column('recur_weekdays', sa.String(length=20), nullable=True)) + op.add_column('tasks', sa.Column('recur_day_of_month', sa.Integer(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('tasks', 'recur_day_of_month') + op.drop_column('tasks', 'recur_weekdays') + op.drop_column('tasks', 'recur_interval_days') + op.drop_column('tasks', 'recur_kind') + op.drop_column('tasks', 'deadline_period') + op.drop_column('tasks', 'deadline_date') + # ### end Alembic commands ### diff --git a/backend/app/api/tasks.py b/backend/app/api/tasks.py index 4e8d3fd..61c61e9 100644 --- a/backend/app/api/tasks.py +++ b/backend/app/api/tasks.py @@ -7,9 +7,18 @@ from app.dependencies import DbDep, UserDep from app.models import Project, Tag, Task, utcnow -from app.schemas import ApproveIn, SuggestIn, TaskCreate, TaskOut, TaskUpdate +from app.schemas import ( + DEADLINE_PERIODS, + RECUR_KINDS, + ApproveIn, + SuggestIn, + TaskCreate, + TaskOut, + TaskUpdate, +) from app.services.detailing import apply_proposal, detail_task from app.services.options import pick_options +from app.services.recurrence import spawn_next router = APIRouter(prefix="/api/tasks", tags=["tasks"]) @@ -100,6 +109,16 @@ raise HTTPException(status_code=422, detail="title cannot be empty") if "status" in data and data["status"] not in VALID_STATUSES: raise HTTPException(status_code=422, detail=f"Unknown status: {data['status']}") + if "deadline_period" in data and data["deadline_period"] not in (None,) + DEADLINE_PERIODS: + raise HTTPException( + status_code=422, detail=f"Unknown deadline period: {data['deadline_period']}" + ) + if "recur_kind" in data and data["recur_kind"] not in (None,) + RECUR_KINDS: + raise HTTPException( + status_code=422, detail=f"Unknown recurrence rule: {data['recur_kind']}" + ) + if "task_type" in data and data["task_type"] not in ("one_time", "recurring"): + raise HTTPException(status_code=422, detail=f"Unknown task type: {data['task_type']}") if "project_id" in data and data["project_id"] is not None: if db.get(Project, data["project_id"]) is None: raise HTTPException(status_code=400, detail="Unknown project") @@ -116,6 +135,11 @@ setattr(task, field, value) if data.get("status") == "done" and task.done_at is None: task.done_at = utcnow() + # Регулярная задача (ТЗ 3.5): после выполнения рождается следующий + # экземпляр — фиксированный календарь, следующий день строго после. + spawned = spawn_next(db, task) + if spawned is not None: + db.flush() db.flush() db.refresh(task) # перечитать связи (project/tags) после обновления diff --git a/backend/app/mcp_server.py b/backend/app/mcp_server.py index 1b91540..b4450fe 100644 --- a/backend/app/mcp_server.py +++ b/backend/app/mcp_server.py @@ -7,6 +7,7 @@ """ import threading +from datetime import date from typing import Any, cast from mcp.server.fastmcp import FastMCP @@ -14,7 +15,9 @@ from app.db import get_session_factory from app.models import Attachment, Project, Tag, Task, utcnow +from app.schemas import DEADLINE_PERIODS, RECUR_KINDS from app.services.detailing import detail_task +from app.services.recurrence import spawn_next VALID_STATUSES = {"to_do", "in_progress", "done", "cancelled", "deferred"} @@ -33,6 +36,13 @@ "tags": [t.name for t in task.tags], "priority": task.priority, "parent_task_id": task.parent_task_id, + "task_type": task.task_type, + "deadline_date": task.deadline_date.isoformat() if task.deadline_date else None, + "deadline_period": task.deadline_period, + "recur_kind": task.recur_kind, + "recur_interval_days": task.recur_interval_days, + "recur_weekdays": task.recur_weekdays, + "recur_day_of_month": task.recur_day_of_month, } @@ -118,9 +128,23 @@ actual_minutes: int | None = None, budget_money: int | None = None, cost_estimate_money: int | None = None, + deadline_date: str | None = None, + deadline_period: str | None = None, + task_type: str | None = None, + recur_kind: str | None = None, + recur_interval_days: int | None = None, + recur_weekdays: str | None = None, + recur_day_of_month: int | None = None, tag_ids: list[int] | None = None, ) -> dict[str, Any]: - """Частично обновить задачу (передавайте только нужные поля).""" + """Частично обновить задачу (передавайте только нужные поля). + + deadline_date — ISO-дата строгого дедлайна; deadline_period — нестрогий: + day|week|month|year. Регулярная задача: task_type="recurring" и правило + recur_kind (interval: recur_interval_days; weekdays: recur_weekdays + "1,3,5" пн=1..вс=7; monthly: recur_day_of_month). При завершении + регулярной задачи следующий экземпляр создаётся автоматически. + """ session = get_session_factory()() try: task = _get_task(session, task_id) @@ -136,6 +160,32 @@ task.status = status if status == "done" and task.done_at is None: task.done_at = utcnow() + if deadline_date is not None: + if not deadline_date.strip(): + task.deadline_date = None + else: + try: + task.deadline_date = date.fromisoformat(deadline_date) + except ValueError: + raise ValueError(f"Invalid deadline_date: {deadline_date}") from None + if deadline_period is not None: + if deadline_period not in (None, "") and deadline_period not in DEADLINE_PERIODS: + raise ValueError(f"Unknown deadline_period: {deadline_period}") + task.deadline_period = deadline_period or None + if task_type is not None: + if task_type not in ("one_time", "recurring"): + raise ValueError(f"Unknown task_type: {task_type}") + task.task_type = task_type + if recur_kind is not None: + if recur_kind not in (None, "") and recur_kind not in RECUR_KINDS: + raise ValueError(f"Unknown recur_kind: {recur_kind}") + task.recur_kind = recur_kind or None + if recur_interval_days is not None: + task.recur_interval_days = recur_interval_days + if recur_weekdays is not None: + task.recur_weekdays = recur_weekdays or None + if recur_day_of_month is not None: + task.recur_day_of_month = recur_day_of_month if project_id is not None: if session.get(Project, project_id) is None: raise ValueError(f"Project {project_id} not found") @@ -158,8 +208,15 @@ if len(tags) != len(set(tag_ids)): raise ValueError("Unknown tag id in tag_ids") task.tags = tags + spawned = None + if task.status == "done" and task.done_at is not None: + # Регулярная задача: следующий экземпляр по фиксированному календарю + spawned = spawn_next(session, task) session.commit() - return _compact(task) + result = _compact(task) + if spawned is not None: + result["spawned_next_id"] = spawned.id + return result finally: session.close() diff --git a/backend/app/models.py b/backend/app/models.py index 89557bb..bace709 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -4,10 +4,19 @@ (см. docs/TZ.md, модель данных). """ -from datetime import UTC, datetime +from datetime import UTC, date, datetime from typing import Any -from sqlalchemy import JSON, Boolean, DateTime, ForeignKey, Integer, String, Text +from sqlalchemy import ( + JSON, + Boolean, + Date, + DateTime, + ForeignKey, + Integer, + String, + Text, +) from sqlalchemy.orm import Mapped, mapped_column, relationship from app.db import Base @@ -50,10 +59,22 @@ estimated_minutes: Mapped[int | None] = mapped_column(Integer, nullable=True, default=None) actual_minutes: Mapped[int | None] = mapped_column(Integer, nullable=True, default=None) # Бюджет опционален (ТЗ 3.4): деньги задаёт пользователь, оценка затрат — рядом - # (целые рубли — дробные деньги в личном планировании не нужны) + # (валюта — глобальная настройка app_settings, суммы — целые) budget_money: Mapped[int | None] = mapped_column(Integer, nullable=True, default=None) cost_estimate_money: Mapped[int | None] = mapped_column(Integer, nullable=True, default=None) + # Дедлайны (ТЗ 3.4) — двух видов: строгий (конкретная дата) и нестрогий + # («в течение дня/недели/месяца/года», период без конкретного дня) + deadline_date: Mapped[date | None] = mapped_column(Date, nullable=True, default=None) + deadline_period: Mapped[str | None] = mapped_column(String(20), nullable=True, default=None) + + # Правило повторения регулярной задачи (ТЗ 3.5): фиксированный календарь — + # следующий экземпляр отсчитывается от даты выполнения по календарю + recur_kind: Mapped[str | None] = mapped_column(String(20), nullable=True, default=None) + recur_interval_days: Mapped[int | None] = mapped_column(Integer, nullable=True, default=None) + recur_weekdays: Mapped[str | None] = mapped_column(String(20), nullable=True, default=None) + recur_day_of_month: Mapped[int | None] = mapped_column(Integer, nullable=True, default=None) + tags: Mapped[list["Tag"]] = relationship( secondary="task_tags", back_populates="tasks", lazy="selectin" ) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index fcc393b..8021158 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -1,12 +1,15 @@ """Pydantic-схемы API M1.""" -from datetime import datetime +from datetime import date, datetime from typing import Any from pydantic import BaseModel, ConfigDict, Field # --- Task --- +DEADLINE_PERIODS = ("day", "week", "month", "year") +RECUR_KINDS = ("interval", "weekdays", "monthly") + class TagOut(BaseModel): model_config = ConfigDict(from_attributes=True) @@ -45,6 +48,15 @@ actual_minutes: int | None = Field(None, ge=1, le=24 * 60) budget_money: int | None = Field(None, ge=0) cost_estimate_money: int | None = Field(None, ge=0) + # Дедлайны: строгий — конкретная дата; нестрогий — период («в течение…») + deadline_date: date | None = None + deadline_period: str | None = None + # Регулярность (ТЗ 3.5): тип задачи + правило повторения + task_type: str | None = None + recur_kind: str | None = Field(None, pattern="^(interval|weekdays|monthly)$") + 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) class TaskOut(BaseModel): @@ -65,6 +77,12 @@ actual_minutes: int | None budget_money: int | None cost_estimate_money: int | None + deadline_date: date | None + deadline_period: str | None + recur_kind: str | None + recur_interval_days: int | None + recur_weekdays: str | None + recur_day_of_month: int | None created_at: datetime approved_at: datetime | None done_at: datetime | None diff --git a/backend/app/services/options.py b/backend/app/services/options.py index a1c1417..333dfe7 100644 --- a/backend/app/services/options.py +++ b/backend/app/services/options.py @@ -6,6 +6,7 @@ учитываются как фолбэк. """ +from datetime import date from typing import Any from sqlalchemy import select @@ -16,10 +17,33 @@ FIT_TOLERANCE = 0.5 +def _priority_key(task: Task) -> int: + return -(task.priority if task.priority is not None else -1) + + def _fits(estimated_minutes: int, available_minutes: int) -> bool: return estimated_minutes <= available_minutes * (1 + FIT_TOLERANCE) +# Срочность дедлайна: чем меньше ранг, тем раньше показываем (дедлайны решают +# пользователя 2026-09-19: учитывать в «3 вариантах») +_PERIOD_RANK = {"day": 0, "week": 1, "month": 2, "year": 3} + + +def _urgency(task: Task) -> tuple[int, Any]: + """Ранг срочности: просроченные и близкие строгие дедлайны — первыми.""" + if task.deadline_date is not None: + days_left = (task.deadline_date - date.today()).days + if days_left < 0: + return (0, days_left) # просрочен + if days_left <= 3: + return (1, days_left) + return (2, task.deadline_date.toordinal()) + if task.deadline_period is not None: + return (3, _PERIOD_RANK.get(task.deadline_period, 3)) + return (4, 0) + + def pick_options(db: Any, available_minutes: int, limit: int = 3) -> list[Task]: """«3 варианта» (ТЗ 3.8): задачи, подходящие под доступное время. @@ -34,6 +58,9 @@ .order_by(Task.priority.desc().nullslast(), Task.created_at.asc()) ) tasks = list(db.scalars(stmt).all()) + # дедлайны повышают срочность: сначала просроченные/близкие строгие, + # затем нестрогие периоды, в пределах ранга — приоритет и свежесть + tasks.sort(key=lambda t: (_urgency(t)[0], _priority_key(t), t.created_at)) active_projects: set[int | None] = {None} for p in db.scalars(select(Project)).all(): diff --git a/backend/app/services/recurrence.py b/backend/app/services/recurrence.py new file mode 100644 index 0000000..58bb290 --- /dev/null +++ b/backend/app/services/recurrence.py @@ -0,0 +1,110 @@ +"""Регулярные задачи (ТЗ 3.5). + +Правила (решение пользователя 2026-09-19): фиксированный календарь — +следующий экземпляр отсчитывается от даты выполнения по календарю: +- interval: каждые N дней от якорной даты (кратно интервалу); +- weekdays: ближайший следующий день из выбранных (ISO: пн=1..вс=7); +- monthly: указанное число месяца (если дня нет — последний день месяца). + +Новый экземпляр создаётся при завершении предыдущего: копируются метаданные, +status=to_do, detail_state=approved, дедлайн-дата не переносится (новому +экземпляру её задаёт пользователь), нестрогий период сохраняется. +""" + +import calendar +from datetime import UTC, date, datetime, timedelta + +from sqlalchemy.orm import Session + +from app.models import Task + +WEEKDAY_NAMES = {1: "пн", 2: "вт", 3: "ср", 4: "чт", 5: "пт", 6: "сб", 7: "вс"} + + +def next_date(task: Task, after: date) -> date | None: + """Дата следующего экземпляра по правилу, строго после `after` (календарь).""" + if task.recur_kind == "interval": + step = task.recur_interval_days or 1 + anchor = task.created_at.date() + # ближайшая дата якоря + k*step, строго больше `after` + k = max(0, (after - anchor).days // step) + 1 + return date.fromordinal(anchor.toordinal() + k * step) + if task.recur_kind == "weekdays": + if not task.recur_weekdays: + return None + days = {int(d) for d in task.recur_weekdays.split(",")} + candidate = after + for _ in range(7): + candidate += timedelta(days=1) + if candidate.isoweekday() in days: + return candidate + return None + if task.recur_kind == "monthly": + if not task.recur_day_of_month: + return None + day = task.recur_day_of_month + year, month = after.year, after.month + while True: + last_day = calendar.monthrange(year, month)[1] + candidate = date(year, month, min(day, last_day)) + if candidate > after: + return candidate + year, month = (year + 1, 1) if month == 12 else (year, month + 1) + return None + + +def recurrence_label(task: Task) -> str: + """Короткое описание правила (для бейджей UI).""" + if task.recur_kind == "interval": + step = task.recur_interval_days or 1 + if step == 1: + return "ежедневно" + if step == 7: + return "еженедельно" + return f"каждые {step} дн." + if task.recur_kind == "weekdays": + if task.recur_weekdays: + return " ".join(WEEKDAY_NAMES[int(d)] for d in task.recur_weekdays.split(",")) + return "по дням недели" + if task.recur_kind == "monthly": + return f"ежемесячно {task.recur_day_of_month}-го" + return "регулярная" + + +def _copy(db: Session, source: Task) -> Task: + """Новый экземпляр регулярной задачи: те же метаданные, чистое состояние.""" + clone = Task( + title=source.title, + description=source.description, + task_type="recurring", + status="to_do", + detail_state="approved", # метаданные уже утверждены пользователем + project_id=source.project_id, + priority=source.priority, + estimated_minutes=source.estimated_minutes, + budget_money=source.budget_money, + cost_estimate_money=source.cost_estimate_money, + deadline_period=source.deadline_period, + recur_kind=source.recur_kind, + recur_interval_days=source.recur_interval_days, + recur_weekdays=source.recur_weekdays, + recur_day_of_month=source.recur_day_of_month, + ) + clone.tags = list(source.tags) + db.add(clone) + return clone + + +def spawn_next(db: Session, task: Task) -> Task | None: + """Создать следующий экземпляр после завершения регулярной задачи. + + Фиксированный календарь: следующий день строго после даты выполнения. + Если следующий день по правилу найти нельзя — None. + Коммит делает вызывающий код. + """ + if task.task_type != "recurring" or task.recur_kind is None: + return None + done_day = (task.done_at or datetime.now(UTC)).date() + if next_date(task, done_day) is None: + return None + return _copy(db, task) diff --git a/backend/tests/test_recurrence.py b/backend/tests/test_recurrence.py new file mode 100644 index 0000000..5700895 --- /dev/null +++ b/backend/tests/test_recurrence.py @@ -0,0 +1,141 @@ +"""Тесты: дедлайны (ТЗ 3.4) и регулярные задачи (ТЗ 3.5).""" + +from datetime import date, timedelta +from typing import Any + +from fastapi.testclient import TestClient + +from app.models import Task +from app.services.options import pick_options +from app.services.recurrence import next_date, recurrence_label, spawn_next +from tests.conftest import _test_session_factory + + +def test_session() -> Any: + return _test_session_factory() + + +# --- дедлайны --- + + +def test_deadlines_set_and_cleared(client: TestClient) -> None: + tid = client.post("/api/tasks", json={"title": "С дедлайном"}).json()["id"] + updated = client.patch( + f"/api/tasks/{tid}", + json={"deadline_date": "2026-10-01", "deadline_period": "week"}, + ).json() + assert updated["deadline_date"] == "2026-10-01" + assert updated["deadline_period"] == "week" + + # неизвестный период — 422 + assert ( + client.patch(f"/api/tasks/{tid}", json={"deadline_period": "fortnight"}).status_code == 422 + ) + + # очистка + cleared = client.patch(f"/api/tasks/{tid}", json={"deadline_date": None}).json() + assert cleared["deadline_date"] is None + + +def test_options_sorted_by_deadline_urgency(client: TestClient) -> None: + # по проекту на задачу: правило «≤1 на проект» не смешивает результаты + def approve(title: str, **patch: Any) -> None: + pid = client.post("/api/projects", json={"name": title}).json()["id"] + tid = client.post("/api/tasks", json={"title": title}).json()["id"] + client.patch(f"/api/tasks/{tid}", json={"project_id": pid, **patch}) + client.post(f"/api/tasks/{tid}/approve") + + tomorrow = (date.today() + timedelta(days=2)).isoformat() + far = (date.today() + timedelta(days=60)).isoformat() + approve("Близкий дедлайн", deadline_date=tomorrow) + approve("Далёкий дедлайн", deadline_date=far) + approve("Мягкий дедлайн", deadline_period="week") + approve("Без дедлайна") + + options = pick_options(test_session(), 600) + titles = [t.title for t in options] + # срочность: близкий строгий → далёкий строгий → нестрогий период; + # лимит 3 — задача без дедлайна остаётся за бортом + assert "Без дедлайна" not in titles + assert titles.index("Близкий дедлайн") < titles.index("Далёкий дедлайн") + assert titles.index("Далёкий дедлайн") < titles.index("Мягкий дедлайн") + + +# --- правила повторения --- + + +def test_next_date_interval() -> None: + task = Task( + title="t", + recur_kind="interval", + recur_interval_days=7, + created_at=__import__("datetime").datetime(2026, 9, 1, 12, 0), + ) + assert next_date(task, date(2026, 9, 3)) == date(2026, 9, 8) + assert next_date(task, date(2026, 9, 8)) == date(2026, 9, 15) # строго после + + +def test_next_date_weekdays() -> None: + task = Task(title="t", recur_kind="weekdays", recur_weekdays="1,3,5") # пн, ср, пт + # 2026-09-19 — суббота; ближайший следующий из пн/ср/пт — понедельник 21-го + assert next_date(task, date(2026, 9, 19)) == date(2026, 9, 21) + + +def test_next_date_monthly_clamps_to_month_end() -> None: + task = Task(title="t", recur_kind="monthly", recur_day_of_month=31) + assert next_date(task, date(2026, 2, 15)) == date(2026, 2, 28) # 2026 не високосный + assert next_date(task, date(2026, 2, 28)) == date(2026, 3, 31) + + +def test_recurrence_label() -> None: + assert recurrence_label(Task(title="t", recur_kind="interval", recur_interval_days=1)) == ( + "ежедневно" + ) + assert recurrence_label(Task(title="t", recur_kind="weekdays", recur_weekdays="1,3")) == ( + "пн ср" + ) + assert recurrence_label(Task(title="t", recur_kind="monthly", recur_day_of_month=1)) == ( + "ежемесячно 1-го" + ) + + +# --- спавн следующего экземпляра --- + + +def test_recurring_task_spawns_next_on_done(client: TestClient) -> None: + tid = client.post("/api/tasks", json={"title": "Полить цветы"}).json()["id"] + client.patch( + f"/api/tasks/{tid}", + json={ + "task_type": "recurring", + "recur_kind": "interval", + "recur_interval_days": 3, + "estimated_minutes": 10, + }, + ) + client.post(f"/api/tasks/{tid}/approve") + client.patch(f"/api/tasks/{tid}", json={"status": "done"}) + + tasks = client.get("/api/tasks?status=to_do").json() + spawned = [t for t in tasks if t["title"] == "Полить цветы"] + assert len(spawned) == 1 + new = spawned[0] + assert new["task_type"] == "recurring" + assert new["detail_state"] == "approved" # метаданные уже утверждены + assert new["estimated_minutes"] == 10 + assert new["recur_kind"] == "interval" + + +def test_one_time_task_does_not_spawn(client: TestClient) -> None: + tid = client.post("/api/tasks", json={"title": "Разовая"}).json()["id"] + client.post(f"/api/tasks/{tid}/approve") + client.patch(f"/api/tasks/{tid}", json={"status": "done"}) + assert client.get("/api/tasks?status=to_do").json() == [] + + +def test_spawn_next_without_rule_is_none() -> None: + task = Task(title="t", task_type="recurring") + session = test_session() + session.add(task) + session.flush() + assert spawn_next(session, task) is None diff --git a/docs/TZ.md b/docs/TZ.md index 14b1d7a..c8de2fa 100644 --- a/docs/TZ.md +++ b/docs/TZ.md @@ -4,7 +4,7 @@ | | | |---|---| -| Версия ТЗ | 0.3 (черновик) | +| Версия ТЗ | 0.4 (черновик) | | Дата | 2026-09-19 | | Статус | На обсуждении | @@ -227,9 +227,9 @@ ## 8. Открытые вопросы -1. **Правила повторения**: минимальный набор (ежедневно / еженедельно / N дней / месяц) — уточнить при проектировании. -2. **Прогнозирование времени**: только по истории похожих задач, или с учётом явных факторов (теги, тип)? -3. **Оценка затрат к бюджету**: откуда берётся оценка затрат — из прогноза времени, вручную, или нужен учёт «доступных средств»? +1. ~~**Правила повторения**~~ — **решено 2026-09-19**: три правила — интервал N дней, дни недели (ISO), день месяца (без дня — последний); якорь — фиксированный календарь от даты выполнения; новый экземпляр рождается при завершении предыдущего. +2. ~~**Прогнозирование времени**~~ — **решено 2026-09-19**: оценка длительности приходит из LLM-детализации (или задаётся вручную); точность не требуется — оценка нужна, чтобы отсекать заведомо большие задачи при малом доступном времени. +3. ~~**Оценка затрат к бюджету**~~ — **решено 2026-09-19**: оба поля (бюджет и оценка затрат) — ручные; валюта — глобальная настройка, выбирается один раз и применяется всюду (UAH, USD, EUR, GBP, PLN). 4. **Оффлайн-режим** в PWA: нужен ли, или всегда онлайн? 5. **Модель для Ollama**: какая конкретно модель и параметры (в конфиг, но нужно выбрать для проверки M2). 6. **Практическое различие `cancelled` / `deferred`**: определения зафиксированы (3.4), но поведение в интерфейсе почти одинаковое — скрыть из активных списков и оставить доступным для возврата. Уточнить при проектировании представлений, нужны ли оба статуса или их поведение сольётся. \ No newline at end of file diff --git a/frontend/src/api.ts b/frontend/src/api.ts index e131616..3c22d24 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -47,6 +47,12 @@ actual_minutes: number | null budget_money: number | null cost_estimate_money: number | null + deadline_date: string | null + deadline_period: string | null + recur_kind: string | null + recur_interval_days: number | null + recur_weekdays: string | null + recur_day_of_month: number | null created_at: string approved_at: string | null done_at: string | null @@ -64,6 +70,13 @@ actual_minutes?: number | null budget_money?: number | null cost_estimate_money?: number | null + deadline_date?: string | null + deadline_period?: string | null + task_type?: string + recur_kind?: string | null + recur_interval_days?: number | null + recur_weekdays?: string | null + recur_day_of_month?: number | null } export interface AppSettings { diff --git a/frontend/src/taskui.ts b/frontend/src/taskui.ts index 9514437..66530f9 100644 --- a/frontend/src/taskui.ts +++ b/frontend/src/taskui.ts @@ -82,4 +82,62 @@ export function formatMoney(amount: number | null): string { if (amount === null) return '' return `${amount.toLocaleString('ru-RU')} ${currencySymbol()}` +} + +// --- Дедлайны и регулярность (ТЗ 3.4, 3.5) --- + +export const DEADLINE_PERIODS: { value: string; label: string }[] = [ + { value: 'day', label: 'в течение дня' }, + { value: 'week', label: 'в течение недели' }, + { value: 'month', label: 'в течение месяца' }, + { value: 'year', label: 'в течение года' }, +] + +export const RECUR_KINDS: { value: string; label: string }[] = [ + { value: 'interval', label: 'Каждые N дней' }, + { value: 'weekdays', label: 'Дни недели' }, + { value: 'monthly', label: 'День месяца' }, +] + +const PERIOD_LABELS: Record = { + day: 'в течение дня', + week: 'в течение недели', + month: 'в течение месяца', + year: 'в течение года', +} + +const WEEKDAY_NAMES = ['пн', 'вт', 'ср', 'чт', 'пт', 'сб', 'вс'] + +export function deadlineLabel(task: { deadline_date: string | null; deadline_period: string | null }): string { + if (task.deadline_date) return `до ${task.deadline_date.split('-').reverse().join('.')}` + return PERIOD_LABELS[task.deadline_period ?? ''] ?? '' +} + +export function deadlineOverdue(task: { deadline_date: string | null }): boolean { + if (!task.deadline_date) return false + const today = new Date().toISOString().slice(0, 10) + return task.deadline_date < today +} + +export function recurrenceLabel(task: { + recur_kind: string | null + recur_interval_days: number | null + recur_weekdays: string | null + recur_day_of_month: number | null +}): string { + if (task.recur_kind === 'interval') { + const step = task.recur_interval_days ?? 1 + if (step === 1) return 'ежедневно' + if (step === 7) return 'еженедельно' + return `каждые ${step} дн.` + } + if (task.recur_kind === 'weekdays') { + if (!task.recur_weekdays) return 'по дням недели' + return task.recur_weekdays + .split(',') + .map((d) => WEEKDAY_NAMES[Number(d) - 1]) + .join(' ') + } + if (task.recur_kind === 'monthly') return `ежемесячно ${task.recur_day_of_month}-го` + return '' } \ No newline at end of file diff --git a/frontend/src/views/ListView.vue b/frontend/src/views/ListView.vue index 1fbe277..4e0a59b 100644 --- a/frontend/src/views/ListView.vue +++ b/frontend/src/views/ListView.vue @@ -1,7 +1,16 @@