diff --git a/backend/app/api/tasks.py b/backend/app/api/tasks.py index 9367a36..53c5604 100644 --- a/backend/app/api/tasks.py +++ b/backend/app/api/tasks.py @@ -170,6 +170,8 @@ ai_eligible: bool | None = Query(None), created_by_kind: str | None = Query(None, pattern="^(user|agent)$"), accept_state: str | None = Query(None, pattern="^(pending|accepted|rejected)$"), + # Тип задачи (0.91): фильтр списка «Регулярные» (ТЗ 3.6) + task_type: str | None = Query(None, pattern="^(one_time|recurring)$"), include_archived: bool = Query(False), ) -> list[Task]: # Завершённые задачи сортируются по дате закрытия, остальные — по добавлению @@ -201,6 +203,8 @@ stmt = stmt.where(Task.created_by_kind == created_by_kind) if accept_state: stmt = stmt.where(Task.accept_state == accept_state) + if task_type: + stmt = stmt.where(Task.task_type == task_type) if tag_id: stmt = stmt.where(Task.tags.any(Tag.id == tag_id)) # Поиск по подстроке имени тега (ТЗ 3.6, 0.70): ввод в списке задач не выбирает diff --git a/backend/app/models.py b/backend/app/models.py index 74cc17f..3c413e7 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -129,7 +129,9 @@ ) # Вид напоминания (task.due_today, summary, …) — тот же ключ, что у текстов kind: Mapped[str] = mapped_column(String(40)) - # Что именно отправлено: id задачи и дата срока, день ритма, дата сводки + # Что именно отправлено: id задачи и дата (срок / день ритма), дата сводки. + # Id задачи обязателен у обоих видов: без него поводы разных задач одного + # пользователя схлопывались в один — за день уходило одно «Пора» на всех (0.91) ref_key: Mapped[str] = mapped_column(String(120)) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) diff --git a/backend/app/services/recurrence.py b/backend/app/services/recurrence.py index df56d32..cd606b3 100644 --- a/backend/app/services/recurrence.py +++ b/backend/app/services/recurrence.py @@ -7,8 +7,13 @@ - monthly: указанное число месяца (если дня нет — последний день месяца). Новый экземпляр создаётся при завершении предыдущего: копируются метаданные, -status=to_do, detail_state=approved, дедлайн-дата не переносится (новому -экземпляру её задаёт пользователь), нестрогий период сохраняется. +status=to_do, detail_state=approved, нестрогий период сохраняется. + +Дата следующего экземпляра (0.91) ставится в его же `deadline_date`. Прежде она +считалась и выбрасывалась, и владелец не видел, когда экземпляр наступит: клон +висел «к выполнению» с первого дня и без срока, неотличимо от задачи на сегодня. +Поле переиспользуем осознанно — для регулярной это «когда пора», а не срок +работы; напоминания об этом сроке не шлются (см. services/reminders.py). """ import calendar @@ -71,11 +76,16 @@ return "регулярная" -def _copy(db: Session, source: Task) -> Task: +def _copy(db: Session, source: Task, next_day: date) -> Task: """Новый экземпляр регулярной задачи: те же метаданные, чистое состояние. Доступность агенту (ТЗ 3.20) наследуется: если владелец делегировал саму задачу, её повтор — та же работа. Автор экземпляра — тот, кто создал правило. + + Дату (`next_day`) ставим только когда у экземпляра нет нестрогого периода: + ТЗ 3.4 запрещает оба поля разом (`TaskView.saveDeadline` на клиенте это + проверяет), а период у регулярной осмыслен сам по себе — «в течение недели» + без привязки к дню. """ clone = Task( user_id=source.user_id, @@ -89,6 +99,7 @@ estimated_minutes=source.estimated_minutes, budget_money=source.budget_money, cost_estimate_money=source.cost_estimate_money, + deadline_date=None if source.deadline_period else next_day, deadline_period=source.deadline_period, recur_kind=source.recur_kind, recur_interval_days=source.recur_interval_days, @@ -113,6 +124,7 @@ 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: + next_day = next_date(task, done_day) + if next_day is None: return None - return _copy(db, task) + return _copy(db, task, next_day) diff --git a/backend/app/services/reminders.py b/backend/app/services/reminders.py index b9b9f7c..3feeaed 100644 --- a/backend/app/services/reminders.py +++ b/backend/app/services/reminders.py @@ -7,9 +7,10 @@ Что напоминаем: -- **строгий дедлайн** — накануне, в день срока и один раз о просрочке. Нестрогий - период («в течение недели/месяца/года») не напоминает вовсе: в модели у него нет - дня отсчёта, и выдумывать якорь владелец не захотел; +- **строгий дедлайн разовой задачи** — накануне, в день срока и один раз о + просрочке. Нестрогий период («в течение недели/месяца/года») не напоминает + вовсе: в модели у него нет дня отсчёта, и выдумывать якорь владелец не захотел. + Регулярных эта ветка не касается (0.91): у них своя дата и свой повод — ритм; - **ритм регулярной задачи** — «пора: зарядка». День берётся тем же правилом, что рождает следующий экземпляр (`services/recurrence.py`), но напоминание не ждёт закрытия предыдущего — иначе о задаче вспоминаешь только тогда, когда её уже @@ -69,8 +70,13 @@ def deadline_reminders(task: Task, today: date) -> list[Reminder]: - """Напоминания о строгом дедлайне: за день, в день и один раз о просрочке.""" - if not is_remindable(task): + """Напоминания о строгом дедлайне: за день, в день и один раз о просрочке. + + У регулярной задачи дата — это дата следующего экземпляра (3.5), а не срок + работы, и напоминает о ней ритм. Без этой оговорки клон получал бы два + уведомления об одном и том же в один день: «Срок сегодня» и «Пора». + """ + if not is_remindable(task) or task.task_type == "recurring": return [] deadline = task.deadline_date if deadline is None: @@ -103,7 +109,10 @@ # проверяется как «следующая дата после вчера равна сегодня» if recurrence.next_date(task, today - timedelta(days=1)) != today: return [] - return [Reminder(KIND_RHYTHM, today.isoformat(), task.id, {"title": task.title})] + # Дата в ключе — как у дедлайна (id задачи + день): без id один пользователь + # получал за день ровно одно «Пора», сколько бы регулярных ни выпало на + # сегодня, — остальные молча съедала уникальность push_deliveries + return [Reminder(KIND_RHYTHM, f"{task.id}:{today.isoformat()}", task.id, {"title": task.title})] def task_reminders(task: Task, today: date) -> list[Reminder]: @@ -168,7 +177,9 @@ for task in tasks: if task.accept_state == "pending": counts["review"] += 1 - if not is_remindable(task): + # Срок регулярной — это дата следующего экземпляра (3.5): в сводке он + # повторял бы ритм, который об этой же задаче и так скажет + if not is_remindable(task) or task.task_type == "recurring": continue days = _days_left(task, today) if days is None: diff --git a/backend/tests/test_push_scheduler.py b/backend/tests/test_push_scheduler.py index 3ffe48e..c921c01 100644 --- a/backend/tests/test_push_scheduler.py +++ b/backend/tests/test_push_scheduler.py @@ -174,6 +174,44 @@ db.close() +def test_two_recurring_tasks_remind_twice(sent: list[tuple[str, str]]) -> None: + """Две регулярные на сегодня — два «Пора», а не одно. + + Ключ дедупа у ритма включает id задачи (0.91). Без него уникальность + (пользователь, вид, ключ) съедала второе напоминание, и владелец узнавал + ровно об одной задаче из двух, сколько бы их ни совпало. + """ + db = _db() + try: + db.add(User(id="1", email="a@b.c", locale="ru")) + db.add(PushSubscription(user_id="1", endpoint="https://push/1", p256dh="p", auth="a")) + for title in ("Зарядка", "Дневник"): + task = Task( + user_id="1", + title=title, + task_type="recurring", + recur_kind="interval", + recur_interval_days=1, + ) + # Якорь интервала — дата создания: ставим вчера, чтобы сегодня + # правило повторения назначило именно TODAY + task.created_at = datetime(2026, 10, 9, 12, 0, tzinfo=TZ) + db.add(task) + db.commit() + finally: + db.close() + + scheduler.run_iteration(MORNING) + assert _kinds(sent) == ["task.rhythm", "task.rhythm"] + + db = _db() + try: + keys = {row.ref_key for row in db.scalars(select(PushDelivery))} + finally: + db.close() + assert len(keys) == 2 + + def test_summary_payload_has_no_task_in_tag(sent: list[tuple[str, str]]) -> None: """У сводки задачи нет: тег «summary:None» в шторке выглядел бы поломкой.""" import json diff --git a/backend/tests/test_recurrence.py b/backend/tests/test_recurrence.py index 8f09dd6..a42f205 100644 --- a/backend/tests/test_recurrence.py +++ b/backend/tests/test_recurrence.py @@ -124,6 +124,34 @@ assert new["detail_state"] == "approved" # метаданные уже утверждены assert new["estimated_minutes"] == 10 assert new["recur_kind"] == "interval" + # дата следующего экземпляра видна сразу (0.91): интервал 3 дня от даты + # выполнения, а не «когда-нибудь» — иначе клон неотличим от задачи на сегодня + assert new["deadline_date"] == (date.today() + timedelta(days=3)).isoformat() + + +def test_spawned_copy_keeps_soft_period_without_date(client: TestClient) -> None: + """Период у регулярной копируется, а даты при нём не будет. + + ТЗ 3.4 запрещает оба поля разом, а «в течение недели» у регулярной осмыслен + сам по себе. Проверяем обе стороны: дата не появилась, период сохранился. + """ + tid = client.post("/api/tasks", json={"title": "Уборка"}).json()["id"] + client.patch( + f"/api/tasks/{tid}", + json={ + "deadline_period": "week", + "task_type": "recurring", + "recur_kind": "interval", + "recur_interval_days": 7, + }, + ) + client.post(f"/api/tasks/{tid}/approve") + client.patch(f"/api/tasks/{tid}", json={"status": "done"}) + + spawned = [t for t in client.get("/api/tasks?status=to_do").json() if t["title"] == "Уборка"] + assert len(spawned) == 1 + assert spawned[0]["deadline_period"] == "week" + assert spawned[0]["deadline_date"] is None def test_one_time_task_does_not_spawn(client: TestClient) -> None: diff --git a/backend/tests/test_reminders.py b/backend/tests/test_reminders.py index 6d40c05..5d35ced 100644 --- a/backend/tests/test_reminders.py +++ b/backend/tests/test_reminders.py @@ -85,13 +85,39 @@ """«Пора: зарядка» — в день, который правило повторения и так назначило.""" daily = _task(task_type="recurring", recur_kind="interval", recur_interval_days=1) assert _kinds(reminders.rhythm_reminders(daily, TODAY)) == [reminders.KIND_RHYTHM] - # ключ — дата: завтра это уже другое напоминание, а сегодняшнее не повторится - assert reminders.rhythm_reminders(daily, TODAY)[0].ref_key == TODAY.isoformat() + # ключ — задача и дата: завтра это уже другое напоминание, а сегодняшнее не повторится + assert reminders.rhythm_reminders(daily, TODAY)[0].ref_key == f"{daily.id}:{TODAY.isoformat()}" assert reminders.rhythm_reminders(daily, TODAY + timedelta(days=1))[0].ref_key != ( - TODAY.isoformat() + f"{daily.id}:{TODAY.isoformat()}" ) +def test_rhythm_keys_of_two_tasks_on_the_same_day_differ() -> None: + """Две регулярные, выпавшие на сегодня, — два напоминания, а не одно. + + Id задачи в ключе обязателен: без него уникальность `push_deliveries` по + (пользователь, вид, ключ) съедала второе «Пора» — за день приходило одно + на пользователя, сколько бы регулярных ни совпало (0.91). + """ + first = _task(task_id=1, task_type="recurring", recur_kind="interval", recur_interval_days=1) + second = _task(task_id=2, task_type="recurring", recur_kind="interval", recur_interval_days=1) + keys = {r.ref_key for t in (first, second) for r in reminders.rhythm_reminders(t, TODAY)} + assert len(keys) == 2 + + +def test_regular_task_has_single_reason() -> None: + """У регулярной повод один — ритм, даже если дата у неё есть. + + С 0.91 клон получает `deadline_date` = дату следующего экземпляра (3.5). + Дедлайновые правила его не касаются: иначе в день срока приходило бы два + уведомления об одном деле — «Срок сегодня» и «Пора». + """ + clone = _task(task_type="recurring", recur_kind="interval", recur_interval_days=1, + deadline_date=TODAY) + assert reminders.deadline_reminders(clone, TODAY) == [] + assert _kinds(reminders.task_reminders(clone, TODAY)) == [reminders.KIND_RHYTHM] + + def test_rhythm_skips_other_days() -> None: """Каждые три дня от 1 сентября: 10 октября — да, 9-е — нет.""" every_three = _task(task_type="recurring", recur_kind="interval", recur_interval_days=3) @@ -104,18 +130,9 @@ assert reminders.rhythm_reminders(_task(task_type="recurring", recur_kind=None), TODAY) == [] -def test_one_task_can_give_both_reminders() -> None: - """Регулярная задача со строгим сроком говорит дважды — это разные поводы.""" - task = _task( - task_type="recurring", - recur_kind="interval", - recur_interval_days=1, - deadline_date=TODAY, - ) - assert _kinds(reminders.task_reminders(task, TODAY)) == [ - reminders.KIND_DUE_TODAY, - reminders.KIND_RHYTHM, - ] +# Прежний тест «одна задача может дать оба повода» ушёл в 0.91: у регулярной +# со сроком два уведомления в один день — это то, что владелец просил убрать +# (см. test_regular_task_has_single_reason выше). # --- Тихие часы --------------------------------------------------------------- diff --git a/backend/tests/test_tasks_api.py b/backend/tests/test_tasks_api.py index 8207e5f..9e5d76c 100644 --- a/backend/tests/test_tasks_api.py +++ b/backend/tests/test_tasks_api.py @@ -122,6 +122,34 @@ assert client.get("/api/tasks", params={"priority_min": 11}).status_code == 422 +def test_list_filter_by_task_type(client: TestClient) -> None: + """Фильтр «Регулярные» (0.91, ТЗ 3.6): список показывает только регулярные.""" + once = create_task(client, "разовая") + daily = create_task(client, "зарядка") + client.patch( + f"/api/tasks/{daily['id']}", + json={"task_type": "recurring", "recur_kind": "interval", "recur_interval_days": 1}, + ) + weekly = create_task(client, "отчёт") + client.patch( + f"/api/tasks/{weekly['id']}", + json={"task_type": "recurring", "recur_kind": "weekdays", "recur_weekdays": "5"}, + ) + + recurring = client.get("/api/tasks", params={"task_type": "recurring"}).json() + assert sorted(t["id"] for t in recurring) == sorted([daily["id"], weekly["id"]]) + + one_time = client.get("/api/tasks", params={"task_type": "one_time"}).json() + assert [t["id"] for t in one_time] == [once["id"]] + + # без параметра фильтра нет — в списке всё + all_ids = {t["id"] for t in client.get("/api/tasks").json()} + assert {once["id"], daily["id"], weekly["id"]} <= all_ids + + # третьего типа не бывает: значение вне перечня отсекает валидация + assert client.get("/api/tasks", params={"task_type": "sometimes"}).status_code == 422 + + def test_list_filter_by_tag_substring(client: TestClient) -> None: """Фильтр tag (0.70) — поиск по подстроке имени тега, без учёта регистра.""" work = client.post("/api/tags", json={"name": "Work"}).json()["id"] diff --git a/docs/TZ.md b/docs/TZ.md index 9abc394..2ddbe01 100644 --- a/docs/TZ.md +++ b/docs/TZ.md @@ -4,7 +4,7 @@ | | | |---|---| -| Версия ТЗ | 0.90 | +| Версия ТЗ | 0.91 | | Дата | 2026-10-10 | | Статус | На обсуждении | @@ -163,9 +163,27 @@ ### 3.5. Регулярные задачи - Отдельный **тип задачи** — регулярная (повторяющаяся) в противовес разовой. -- Повторение создаёт новую задачу по правилу после выполнения/истечения предыдущей. -- Точный набор правил повторения (ежедневно/еженедельно/интервалы) — уточняется. +- Повторение создаёт новую задачу по правилу **после закрытия** предыдущей + (закрытие — единственный момент, когда рождается следующий экземпляр: отмена и + «отложено» цепочку не продолжают). +- Правила повторения (реализованы с 0.4): **интервал** — каждые N дней от даты + создания, **дни недели** — ближайший следующий из выбранных, **месячное число** — + указанное число месяца (нет такого дня — последний день месяца). Отсчёт идёт по + календарю, а не «через N суток после выполнения». - Новый экземпляр наследует **доступность для ИИ-агента** (3.20) от предыдущего: цепочка регулярной не теряет мандат. +- **Дата следующего экземпляра видна сразу** (0.91). Она вычисляется тем же + правилом, что и рождение экземпляра, и до 0.91 выбрасывалась: закрыв ежедневную + задачу утром, владелец видел её же снова «к выполнению», без даты и неотличимо от + задачи на сегодня. Теперь она ложится в существующее поле `deadline_date` + (отдельной сущности и миграции нет). Поле переиспользуется осознанно: у + регулярной это **«когда пора»**, а не срок работы, поэтому напоминания о сроках + (3.21) её не касаются, а подпись даты в интерфейсе читается как «след. 12.10.2026». + Если у экземпляра задан **нестрогий период** («в течение недели»), дата не + ставится: 3.4 запрещает оба поля разом, а период у регулярной осмыслен сам по себе. +- **Метка в интерфейсе** (0.91): в карточке задачи (3.6) регулярная отмечена + **акцентной полосой по левой кромке** и **кружком с иконкой повтора** перед + названием — задача опознаётся по типу, не читая заголовок и бейджи; подсказка + кружка говорит правило словами и называет дату следующего экземпляра. ### 3.6. Представления задач @@ -214,6 +232,12 @@ (`tag_id`, `priority`) остаются — их держат тесты и старые клиенты: MCP-инструмент `list_tasks` фильтрует по тегу своей выборкой в БД, REST-параметр не использует. Задачи без приоритета (`NULL`) отсечку `priority_min` не проходят. +- **Фильтр по типу задачи** (0.91): параметр `task_type` у `GET /api/tasks` + (`one_time` | `recurring`, иное значение — 422), которым работает переключатель + **«Регулярные»** — второй `GnSwitch` рядом с «Приоритетными» в шапке списка + (3.5): «показать главное» и «показать повторяющееся» — два вопроса об одном + списке, и читаются они парой. Фильтры списка применяет сервер (как и остальные), + персистентности у них нет — по 3.8 запоминаются фильтры только на странице проекта. - **Фильтры-поля — в шапке страницы** (0.75): переключатель «Приоритетные» и поиск по тегу собраны справа от заголовка (слот действий `GnPageHeader`) одной группой; отдельной строкой под шапкой остались только табы статуса. На узком экране группа @@ -1392,8 +1416,8 @@ **9. Напоминания (0.89).** Приложение молчит, пока его не откроют, поэтому о сроке и о регулярном деле напоминает планировщик — не только открытая вкладка. -*Что напоминаем.* **Строгий дедлайн** — накануне срока, в день срока и один раз о -просрочке. **Ритм регулярной задачи** — «пора: зарядка» в день, который правило +*Что напоминаем.* **Строгий дедлайн разовой задачи** — накануне срока, в день срока и +один раз о просрочке. **Ритм регулярной задачи** — «пора: зарядка» в день, который правило повторения (3.5) и так назначило; день считается тем же `recurrence.next_date`, что создаёт следующий экземпляр, отдельного «календаря напоминаний» нет, иначе правила разъехались бы. Следствием этого задача, созданная сегодня, впервые напомнит о себе в @@ -1402,6 +1426,12 @@ по локальному времени и молчит (не отправляется и не пишется в журнал), если сказать нечего. +*У регулярной повод один — ритм* (0.91). С тех пор как экземпляр получает дату +следующего раза (3.5), дедлайновые правила его не касаются: иначе в день срока об одном +деле приходило бы два уведомления — «Срок сегодня» и «Пора». По той же причине +регулярные не попадают в счётчики сводки «просрочено» и «срок сегодня» (в «ждут приёмки» +попадают: закрытая агентом работа ждёт решения владельца независимо от типа задачи). + *Что не напоминаем.* **Нестрогий срок** («в течение недели/месяца/года») не напоминает вовсе: в модели это период без дня отсчёта, разворачивать его в дату мы не стали. Задачи в статусах `done`, `cancelled` и **`deferred`** — тоже молчание: «сейчас не в @@ -1414,8 +1444,11 @@ *Дедуп.* Отправленное записывается в `push_deliveries` (`unique(user_id, kind, ref_key)`) — **до** отправки. Поэтому повторный тик и перезапуск контейнера дублей не -дают, а перенос срока рождает новый повод: в `ref_key` входит дата срока, у ритма — -день, о котором напоминаем. Обратный размен (падение между записью и отправкой) даёт +дают, а перенос срока рождает новый повод: в `ref_key` входят **id задачи и дата** — +у дедлайна дата срока, у ритма день, о котором напоминаем. Id обязателен (0.91): без +него ключ был общим на пользователя, и, если на сегодня выпадали две регулярные, второе +«Пора» молча съедала уникальность — за день приходило одно напоминание, сколько бы +задач ни совпало. Обратный размен (падение между записью и отправкой) даёт пропуск, а не дубль: для напоминания это правильная сторона ошибки. *Тихие часы.* Настройка `quiet_hours` («HH:MM-HH:MM», `app_settings`) — окно, в которое @@ -1499,6 +1532,10 @@ deadline nullable — дедлайн: strict { date } — «до конкретной даты» soft { period: week|month|year } — «в течение периода» + (0.91) у регулярной задачи строгая дата — дата следующего + экземпляра (3.5), а не срок работы; оба поля разом + запрещены (3.4), поэтому экземпляр с нестрогим периодом + даты не получает recurrence nullable — правило повторения (для task_type = recurring) time_estimate nullable — прогноз времени (система) budget nullable — ручной бюджет (деньги и/или время), опционален @@ -1628,7 +1665,7 @@ | M5 — MCP | MCP-сервер, инструменты для агентов | | M5+ — мандат агента | Доступность задачи для ИИ-агента, взятие в работу (аренда), приёмка работы, журнал `task_events` (0.85, 3.20) | | M6 — мобильность | PWA (Android-приложение — позже, вне стартового скоупа) | -| M6+ — уведомления | Системные уведомления (Web Push): подписки устройств, ключи VAPID, блок настроек, работа агента в закрытом приложении (0.88, 3.21). Напоминания: дедлайны, ритм регулярных задач, утренняя сводка, тихие часы (0.89, 3.21) | +| M6+ — уведомления | Системные уведомления (Web Push): подписки устройств, ключи VAPID, блок настроек, работа агента в закрытом приложении (0.88, 3.21). Напоминания: дедлайны, ритм регулярных задач, утренняя сводка, тихие часы (0.89, 3.21). Регулярные задачи: дата следующего экземпляра у клона, метка в карточке, фильтр «Регулярные» (0.91, 3.5) | | M7 — упаковка | Docker (docker-compose: API, PostgreSQL, фронт, Ollama + модель) | Порядок M4/M5 может меняться — MCP можно поднять раньше ради ИИ-сценария. diff --git a/frontend/src/components/AchievementsPanel.vue b/frontend/src/components/AchievementsPanel.vue index dd09279..f7918b6 100644 --- a/frontend/src/components/AchievementsPanel.vue +++ b/frontend/src/components/AchievementsPanel.vue @@ -60,8 +60,12 @@ (sum, task) => sum + (task.actual_minutes ?? task.estimated_minutes ?? 0), 0, ) - // Дедлайн-босс: закрытые со строгим дедлайном-датой - const strictDone = done.filter((task) => task.deadline_date).length + // Дедлайн-босс: закрытые со строгим дедлайном-датой. Регулярные не считаем + // (0.91): с 3.5 у экземпляра есть дата следующего раза, и привычка «зарядка + // ежедневно» сама накручивала бы ачивку — это не работа в срок + const strictDone = done.filter( + (task) => task.deadline_date && task.task_type !== 'recurring', + ).length // «Выбрал и сделал»: закрыто через режим «3 вариантов» const viaOptions = props.events.filter((e) => e.via_options).length // Преодоление: закрытые «ментально сложные» (метка «не хочется делать») diff --git a/frontend/src/components/TaskCard.vue b/frontend/src/components/TaskCard.vue index 20fb780..3c2b814 100644 --- a/frontend/src/components/TaskCard.vue +++ b/frontend/src/components/TaskCard.vue @@ -3,7 +3,7 @@ import { useRoute, useRouter } from 'vue-router' import { useI18n } from 'vue-i18n' import type { Task } from '../api' -import { formatDate, proposalItems, statusOptions, timeAgo } from '../taskui' +import { formatDate, proposalItems, recurrenceLabel, statusOptions, timeAgo } from '../taskui' import TaskBadges from './TaskBadges.vue' import TaskMoveDialog from './TaskMoveDialog.vue' @@ -108,11 +108,38 @@ const createdTitle = computed(() => props.task.created_at ? formatDate(props.task.created_at) : '', ) + +// Метка регулярной задачи (0.91, ТЗ 3.5). Раньше регулярную в списке было не +// отличить от разовой: правило жило только в бейдже среди прочих бейджей, и +// владелец, закрыв ежедневную задачу, видел её же снова «к выполнению» без даты. +// Теперь два независимых канала — «видно, не читая»: кружок с иконкой повтора +// перед названием и акцентная полоса по левой кромке карточки. +const isRecurring = computed(() => props.task.task_type === 'recurring') +// Подсказка метки — словами о правиле и о следующем экземпляре: иконка отвечает +// «что это», подсказка — «как это работает» +const recurHint = computed(() => { + const rule = recurrenceLabel(props.task) || t('taskui.recur.recurring') + return props.task.deadline_date + ? t('taskui.recur.hintNext', { rule, date: formatDate(props.task.deadline_date) }) + : t('taskui.recur.hint', { rule }) +})