"""Регулярные задачи (ТЗ 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)