diff --git a/backend/app/api/tasks.py b/backend/app/api/tasks.py index 647b312..0288e28 100644 --- a/backend/app/api/tasks.py +++ b/backend/app/api/tasks.py @@ -130,7 +130,11 @@ status: str | None = Query(None), project_id: int | None = Query(None), tag_id: int | None = Query(None), + tag: str | None = Query(None, description="Подстрока имени тега (без учёта регистра)"), priority: str | None = Query(None), + priority_min: int | None = Query( + None, ge=0, le=10, description="Нижняя граница приоритета (шкала 0-10)" + ), parent_id: int | None = Query(None), include_archived: bool = Query(False), ) -> list[Task]: @@ -159,6 +163,12 @@ stmt = stmt.where(Task.parent_task_id == parent_id) if tag_id: stmt = stmt.where(Task.tags.any(Tag.id == tag_id)) + # Поиск по подстроке имени тега (ТЗ 3.6, 0.70): ввод в списке задач не выбирает + # тег из выпадашки, а ищет по нему. autoescape глушит служебные знаки LIKE — + # иначе «%» во вводе совпал бы с чем угодно, а «_» — с любым символом + query = (tag or "").strip() + if query: + stmt = stmt.where(Task.tags.any(Tag.name.icontains(query, autoescape=True))) # Фильтр по приоритету (ТЗ 3.6) — по градациям шкалы 0-10 (см. 3.13): # без приоритета задача считается «very_low» if priority: @@ -176,6 +186,11 @@ stmt = stmt.where(or_(Task.priority.is_(None), Task.priority <= high)) else: stmt = stmt.where(Task.priority.between(low, high)) + # Отсечка по нижней границе шкалы (ТЗ 3.6, 0.74) — ею работает переключатель + # «Приоритетные» в списке задач: «выше среднего» = high+urgent, то есть 7-10. + # Задачи без приоритета (very_low, NULL) не проходят сравнение — так и надо + if priority_min is not None: + stmt = stmt.where(Task.priority >= priority_min) return list(db.scalars(stmt).all()) diff --git a/backend/tests/test_tasks_api.py b/backend/tests/test_tasks_api.py index df549fc..8207e5f 100644 --- a/backend/tests/test_tasks_api.py +++ b/backend/tests/test_tasks_api.py @@ -99,6 +99,64 @@ assert bad.status_code == 422 +def test_list_filter_by_priority_min(client: TestClient) -> None: + """Отсечка priority_min (0.74): ею работает переключатель «Приоритетные».""" + none = create_task(client, "без приоритета") + medium = create_task(client, "средняя") + client.patch(f"/api/tasks/{medium['id']}", json={"priority": 6}) + high = create_task(client, "высокая") + client.patch(f"/api/tasks/{high['id']}", json={"priority": 7}) + urgent = create_task(client, "срочная") + client.patch(f"/api/tasks/{urgent['id']}", json={"priority": 9}) + + # 7 — нижняя граница градации «high» (ТЗ 3.13): средняя и задачи без + # приоритета (very_low) в отсечку «выше среднего» не попадают + top = client.get("/api/tasks", params={"priority_min": 7}).json() + assert sorted(t["id"] for t in top) == sorted([high["id"], urgent["id"]]) + + # без параметра отсечки нет + all_ids = {t["id"] for t in client.get("/api/tasks").json()} + assert {none["id"], medium["id"], high["id"], urgent["id"]} <= all_ids + + # вне шкалы 0-10 отсекает валидация + assert client.get("/api/tasks", params={"priority_min": 11}).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"] + home = client.post("/api/tags", json={"name": "Home"}).json()["id"] + call = create_task(client, "созвон") + clean = create_task(client, "уборка") + client.patch(f"/api/tasks/{call['id']}", json={"tag_ids": [work]}) + client.patch(f"/api/tasks/{clean['id']}", json={"tag_ids": [home]}) + + # подстрока «wor» и другой регистр «WORK» находят задачу с тегом «Work». + # Регистр проверяем на латинице: в SQLite (тестовая БД) lower() без учёта + # регистра работает только для ASCII, в проде Postgres — для любого текста + for needle in ("wor", "WORK"): + found = client.get("/api/tasks", params={"tag": needle}).json() + assert [t["id"] for t in found] == [call["id"]], needle + + # кириллица: подстрока ищется, регистр берём как в теге + client.post("/api/tags", json={"name": "домашние"}).json() + tag_id = next(t["id"] for t in client.get("/api/tags").json() if t["name"] == "домашние") + client.patch(f"/api/tasks/{clean['id']}", json={"tag_ids": [home, tag_id]}) + assert len(client.get("/api/tasks", params={"tag": "машн"}).json()) == 1 + + # пустой и пробельный ввод — фильтр не применяется + assert len(client.get("/api/tasks", params={"tag": ""}).json()) == 2 + assert len(client.get("/api/tasks", params={"tag": " "}).json()) == 2 + + # служебные знаки LIKE не работают как маска + assert client.get("/api/tasks", params={"tag": "%"}).json() == [] + + # комбинируется с другими фильтрами + combo = client.get("/api/tasks", params={"tag": "wor", "status": "to_do"}).json() + assert [t["id"] for t in combo] == [call["id"]] + assert client.get("/api/tasks", params={"tag": "wor", "status": "done"}).json() == [] + + def test_delete_task(client: TestClient) -> None: task = create_task(client) assert client.delete(f"/api/tasks/{task['id']}").json() == {"ok": True} diff --git a/docs/TZ.md b/docs/TZ.md index f2d19c5..becf408 100644 --- a/docs/TZ.md +++ b/docs/TZ.md @@ -4,7 +4,7 @@ | | | |---|---| -| Версия ТЗ | 0.67 | +| Версия ТЗ | 0.77 | | Дата | 2026-10-10 | | Статус | На обсуждении | @@ -156,9 +156,55 @@ ### 3.6. Представления задач -- **Классический вид**: список с фильтрами (проект, тег, статус, приоритет). - Фильтр приоритета — по градациям шкалы 0-10 (очень низкий…срочный); задача без - приоритета попадает в «очень низкий». +- **Классический вид**: список с фильтрами (статус, приоритет, тег). + Шкала приоритета — 0-10 с градациями (очень низкий…срочный, 3.13); задача без + приоритета считается «очень низким». В интерфейсе фильтр приоритета — включение + «приоритетные» (выше среднего, то есть high и urgent), отсечка по градациям + остаётся в API. +- **Статус — табами, тег — поиском по имени** (0.70): выпадашки не помогали при + росте данных. Пять статусов спрятаны в селекте — теперь это **табы** + («Все» + пять статусов, иконки как в бейджах статусов); на узком экране табы + **прокручиваются по горизонтали** внутри своего ряда, страница по горизонтали не + едет. Тегов у пользователя десятки — найти нужный в селекте глазами нельзя, + поэтому тег задаётся **поиском по подстроке имени** (без учёта регистра): список + задач сужается **по мере ввода**, а не после выбора тега из подсказок; пустой + ввод фильтр не применяет. Подсказки (совпавшие теги) показывает `GnCombobox` кита. + Доработка 0.71: подсказки показываются **только под набранное** — на фокусе + пустого поля вместо простыни из всех тегов стоит подсказка «начните вводить + название тега». Внутри поля, справа — **крестик сброса**: у поиска нет пункта + «— любой —», а фильтр ещё и запоминается (3.8), поэтому снять его иначе было + нечем; пустой ввод после сброса применяется сразу, без задержки ввода. + Доработка 0.73: фильтра **по проекту в списке нет** — разрез по проектам это + раздел «Проекты» (3.8), в общем списке селект только дублировал его (со страницы + ушла и загрузка списка проектов — она была нужна лишь этому фильтру). + Подписи у поля тега нет (0.73): табы подписаны собой, а у поля есть иконка тега и + подсказка; поле при этом стало **300px**, как на странице проекта (0.71), иначе имя + тега в нём не читалось. + Доработка 0.74: фильтр приоритета — **один переключатель «Приоритетные»** + (`GnSwitch` кита) вместо табов приоритета (они прожили только 0.73): из шести + пунктов нужен был ровно один вопрос — «показать главное». Включённый переключатель + оставляет задачи **выше среднего**: градации high и urgent (7-10 по шкале 3.13). + Задача без приоритета (very_low, пустое значение) в отсечку не попадает — она и + есть самая низкая. Переключатель стоит рядом с полем тега (с 0.75 — в шапке + страницы, см. ниже) и выровнен по центру поля: короткий контрол при выравнивании + по низу липнул бы к нижнему краю 50-пиксельной коробки. Доработка 0.76: в шапке + выровнены и сами элементы друг относительно друга — у кита под полем остаётся + `margin-bottom` 15px у `form-group`, а над полем 8px у `input`, из-за чего поле + стояло на 3.5px выше центра ряда; в фильтре эти отступы сняты, и обёртка поля + совпадает с его коробкой. Крестик сброса отсчитывается от низа обёртки, поэтому + до 0.76 «уплывал» к нижнему краю поля на те же 15px. +- **API списка** принимает подстрочный фильтр тега параметром `tag` у + `GET /api/tasks` (0.70; регистронезависимо, служебные знаки `%` и `_` во вводе + экранируются — «100%» не превращается в маску) и отсечку по нижней границе + приоритета параметром `priority_min` (0.74; целое 0-10, вне шкалы — 422), которой + работает переключатель «Приоритетные» (`priority_min=7`). Параметры по `id`/градации + (`tag_id`, `priority`) остаются — их держат тесты и старые клиенты: MCP-инструмент + `list_tasks` фильтрует по тегу своей выборкой в БД, REST-параметр не использует. + Задачи без приоритета (`NULL`) отсечку `priority_min` не проходят. +- **Фильтры-поля — в шапке страницы** (0.75): переключатель «Приоритетные» и поиск + по тегу собраны справа от заголовка (слот действий `GnPageHeader`) одной группой; + отдельной строкой под шапкой остались только табы статуса. На узком экране группа + переносится по строкам сама (переключатель строкой, поле — следующей). - **Пагинация**: длинные списки (список задач, стек на главной, задачи проекта, проекты, архив) разбиваются на страницы **по 30 элементов**; навигация кнопками (‹ номера ›, с усечением «…» и подписью «Страница N из M»). Смена фильтров @@ -166,6 +212,26 @@ - Подзадачи показываются карточками внутри задачи и в списке; представление «дерево» убрано (проба 0.5 показала, что оно мешает — возможен возврат позже). Связь подзадачи с родителем (`parent_task_id`) в модели данных сохраняется. +- Карточки списка задач раскладываются **сеткой по три в ряд** (0.68; на десктопе + контейнер расширен до 1440px, см. 4): до 1200px — две колонки, до 900px — одна + (там список остаётся колонкой флексом). Скелетон загрузки повторяет ту же сетку, + чтобы загрузка не дёргала раскладку. +- **Карточка задачи кликабельна целиком** (0.75): клик мимо внутренних элементов + ведёт на страницу задачи. Свои интерактивные элементы карточка не перехватывает — + заголовок-ссылка, **бейдж проекта** (с 0.75 это тоже ссылка, на страницу проекта; + при наведении бейдж подсвечивается акцентом) и «⋯»-меню обрабатывают клик сами — + иначе получалась бы двойная навигация. Выделенный в карточке текст кликом не + считается: иначе не скопировать сниппет. Курсор и подсветка рамки при наведении — + как у карточки проекта. +- **Свежесть задачи на карточке** (0.75): когда задача добавлена, словами + («Сегодня», «Вчера», «3 дня назад», «2 недели назад» — тот же `timeAgo`, что у + свежести проекта, 0.69); полная дата — в подсказке. Это контекст, а не метаданные, + поэтому не бейдж, а приглушённый текст; на странице задачи дата добавления как + была — в сайдбаре «Даты» в полном формате. С 0.76 живёт в **подвале карточки** + (слот `footer` китового `GnCard`), а не в ряду бейджей: подвал отделён чертой + 1px и прижат к низу карточки, поэтому в сетке списка он у всех карточек на одной + линии. Иконка рядом с текстом идёт с китовым классом `normalize` — без него глиф + иконочного шрифта садится на 1.7px выше середины строки. ### 3.7. Главная страница (дашборд + стек) @@ -190,9 +256,10 @@ заголовки секций показываются только когда пустые проекты есть, иначе список выглядит как раньше. Порядок применяется **до** пагинации (3.6), чтобы секция не разъезжалась по страницам. -- Карточки проектов раскладываются **сеткой по две в ряд** (0.61): в две колонки - список читается компактнее, на узких экранах — одна колонка (ломающаяся точка - 860px). Заголовок секции занимает всю ширину строки; скелетон загрузки повторяет +- Карточки проектов раскладываются **сеткой по три в ряд** (0.68; в 0.61 их было + две): в три колонки список читается компактнее и помещается в широкий контейнер + (1440px, см. 4), а на узких экранах — две колонки до 1200px и одна до 860px. + Заголовок секции занимает всю ширину строки; скелетон загрузки повторяет ту же сетку. Карточки ряда **одной высоты с подвалом по низу** (0.66; в 0.61 они выравнивались по верху и подвал «плясал» между соседями). - **Карточка в списке — метрики, без заметки** (0.62): заметка (Markdown с ресурсами) @@ -200,8 +267,9 @@ и мешала сравнивать проекты. В шапке карточки справа — актуальность; ниже ряд метрик: приоритет, «N в работе», «просрочено N» (нулевые метрики не показываются — иначе ряд бейджей шумит одинаковыми «0»); полоса прогресса «Закрыто N из M» - с процентом (оформление полосы изменено в 0.64); подвал — ближайший дедлайн - по открытым задачам (просроченный красным). + с процентом (оформление полосы изменено в 0.64); подвал виден всегда (0.69) — + слева свежесть словами, справа ближайший дедлайн по открытым задачам + (просроченный красным). У проекта без задач вместо полосы — подсказка «Задач в проекте пока нет». Метрики считаются по всем задачам проекта (как и полоса прогресса), просрочка — по незакрытым. @@ -213,9 +281,12 @@ при 2..24 задачах: у одной задачи дробить нечего, при большем числе клетка становится уже 4px и полоса рябит — там она сплошная. Заливка считается по клеткам (`100%/M × N`), а не процентом кита: у процента округление до целого, - и на границе клетки оставалась бы щель. Проект закрыт целиком — полоса и счётчик - уходят в зелёный кита. Цвет проекта **в тексте** не используется (тёмные оттенки - палитры, светлота от 38%, нечитаемы как текст) — им закрашена только полоса. + и на границе клетки оставалась бы щель. Трек на карточке **тоньше китовских + 18px — 8px** (0.68): полоса здесь подложка под счётчиком, а не самостоятельный + элемент. Проект закрыт целиком — полоса и счётчик + уходят в зелёный кита. Цвет проекта **в тексте** не используется (тёмная ступень + цветных рядов и тёмный край нейтральной шкалы, 0.72 — нечитаемы как текст) — им + закрашена только полоса. - **Метка проекта — кружок и фавикон сайта** (0.65): в шапке страницы проекта и на карточке списка проект помечен парой «цвет-кружок + иконка сайта»: на карточке кружок 16px и фавикон 18px (было 9px без иконки), в шапке страницы — 18px и 21px @@ -240,14 +311,35 @@ (3.8.1) кнопка «Открыть» остаётся — там рядом действия восстановления/удаления. - **Цвет-метка проекта** (0.63): у проекта может быть цвет-кружок — «узнаваемость» проекта в списках. Поле необязательное: новым проектам цвет **не назначается**, - пока пользователь сам не выберет (пусто — кружка нет). Выбор — палитра ≈100 - оттенков, выведенных из 8 базовых цветов кита (3.19), на странице проекта - (инлайн-строка «Цвет») и в форме правки; в БД — HEX `#rrggbb` (палитра — - представление фронта, бэкенд проверяет только формат, поэтому цвет из MCP - вне палитры тоже валиден и показывается отдельной клеткой). Показывается - в трёх местах: **карточка в списке проектов, шапка страницы проекта, метка + пока пользователь сам не выберет (пусто — кружка нет). Выбор — палитра + **27 оттенков** из 8 базовых тонов (3.19), на странице проекта (инлайн-строка + «Цвет») и в форме правки; в БД — HEX `#rrggbb` (палитра — представление + фронта, бэкенд проверяет только формат, поэтому цвет из MCP вне палитры тоже + валиден и показывается отдельной клеткой). Показывается в трёх местах: + **карточка в списке проектов, шапка страницы проекта, метка проекта у задач** (списки, дашборд, страница задачи). В фильтрах и селектах проектов цвета нет: в выпадашке это шум, а не узнаваемость. + Базовая палитра уточнена в 0.69: **ряд «песочного» (warning кита, оттенок 35°) + заменён своим жёлтым** (`#e5cf5a`, оттенок 50°) — рядом с оранжевым (25°) он + читался вторым оранжевым, а не жёлтым; жёлтого тона в ките нет вовсе, поэтому + база своя. Добавлен **ряд нейтральных от белого** — он последний в сетке: + цветные метки узнаются быстрее, а серые нужны редко и на своём месте не мешают. + Палитра пересобрана в 0.72 по замечанию «слишком одинаковые цвета»: клеток + стало меньше, но **любые две клетки сетки различимы**. Убраны приглушённые + варианты (тот же тон с половинной насыщенностью) — именно они и давали + «одинаковые» клетки: и рядом со светлой ступенью своего ряда, и с приглушёнными + чужих тонов. Из базовых тонов убран **голубой** `#7dcfff` (199°): он стоял + в 12° от циана и в 20° от синего — три холодных клетки читались одним синим; + добавлен **коричневый** `#8c5e35` (28°, светлота 38%, насыщенность 45%) — + коричневого тона в ките нет, и это свой цвет, а не «тёмный оранжевый». + Ступени считаются **от светлоты базового** (светлая +18%, тёмная −24%), а не + заданы абсолютной шкалой: у тона, стоящего у предела светлоты (36..88%), + ступени просто нет, поэтому ряды коричневого и сиреневого короче — это честнее + клетки, не отличимой от соседней. Разница измеримая: минимальная попарная + разница по CIELAB — **ΔE 12.5**. Ряд нейтральных сокращён с 12 ступеней + до **5** через 16%: от белого `#ffffff` до `#5c5c5c`. До чёрного шкала + не доходит намеренно: чёрная метка на тёмной карточке кита не видна, то есть + была бы выбором без результата (раньше это называлось ценой полноты шкалы). - **Сайт проекта** (0.63): ссылка на сайт проекта — отдельное поле, отдельно от заметки (в заметке ссылка тонула в тексте). Показывается **только на странице проекта**: в шапке — имя хоста с иконкой сайта, клик открывает сайт в новой @@ -298,14 +390,42 @@ считаются **по всему списку**, не по странице: иначе на второй странице заголовки то появлялись бы, то исчезали. - **Подвал карточки — отсчёт до дедлайна** (0.66): ближайший срок показывается - словами — «сегодня», «завтра», «через N дн.», «просрочено на N дн.» (просрочка + словами — «сегодня», «завтра», «через 3 дня», «просрочено на 5 дней» (просрочка красным), полная дата уходит в подсказку при наведении: дата без отсчёта - требовала сравнения с календарём на каждом проекте. Сокращение «дн.» — потому что - плюрализации в проекте нет (счётчики везде одной формой), а «3 дня / 5 дней» - потребовали бы форм в каждой локали. Дедлайн отсчитывается по календарным суткам + требовала сравнения с календарём на каждом проекте. Формы числа — по правилу + локали (0.69, как у подписи свежести, см. ниже): в 0.66 это было «через N дн.» — + сокращение из-за отсутствия плюрализации, теперь формы полные. Дедлайн + отсчитывается по календарным суткам (без часовых поясов и перехода на летнее время). Красный в подвале — про сам ближайший срок, а не про наличие просроченных задач: бейдж «просрочено N» в ряду метрик остаётся независимым. +- **Подвал карточки виден всегда, слева — свежесть** (0.69): подвал перестал быть + местом только для дедлайна. Слева теперь свежесть проекта — **когда последний раз + была активность по задачам**, словами: «сегодня», «вчера», «3 дня назад», + «2 недели назад», «месяц назад», «4 месяца назад», «год назад» (та же активность, + по которой сортируется список, 0.66: позднейшая из `created_at`/`done_at` задач; + правки самого проекта не считаются); полная дата — в подсказке. Срок при этом + стоит справа — важное в одном и том же углу у всех карточек, независимо от того, + есть ли свежесть. Сутки календарные, ниже недели — дни, дальше округление вниз до + крупной единицы (45 дней — это уже месяцы, 400 — годы). **Появились + плюральные формы**: их выбирает правило самой локали (`Intl.PluralRules`) — + «2 дня», а не «2 день»; в словаре формы записаны одной строкой через «|» (ru/uk — + три, en — две), выбор формы в коде не хардкодится. Число при единственном числе + остаётся («1 месяц назад»). У проекта без задач свежести нет — строка пустая, но + место подвала сохраняется, чтобы низ карточек в ряду не разъезжался. +- **Фильтры задач проекта — статус табами, тег поиском** (0.70): на странице проекта + тот же форм-фактор, что в списке задач (3.6) — табы статуса («Все» + пять статусов, + горизонтальная прокрутка на узком экране) и поиск по подстроке имени тега (с + подсказкой на пустом вводе и крестиком сброса внутри поля, 0.71 — как в 3.6). + Подписи у поля нет: фильтр на странице один, и он шире, чем в списке — + **300px** вместо 224px, иначе имя тега в нём не читалось (0.71). Тег здесь + фильтрует список уже загруженных задач проекта — по имени, без учёта регистра. + Выбранные фильтры (статус и текст поиска тега) **запоминаются в + localStorage** отдельно для каждого проекта и пользователя: вернувшись на страницу, + видишь те же фильтры. В приватном окне или после очистки данных страница просто + работает на значениях по умолчанию (по статусу — «К выполнению»). Сохранённый + прежними версиями выбор тега по `id` не восстанавливается осознанно: имя тега по + `id` известно только после загрузки справочника. #### 3.8.1. Архив @@ -760,6 +880,56 @@ язык (панели/карточки — радиус 6px и бордер 2px `--gn-border-color-muted`, мелкие контролы/инсеты — 3px; темы Verdant/Ember/Mono и дизайн-токены `--gn-*` доступны, но не используются пока в gntodo). +- **Полосы прокрутки — в языке кита** (0.76): свою полосу кит рисует только + меню-дроверу (`.nav-drawer-body`: тонкая, бегунок акцентом `#7aa2f7` по фону + панели), всё остальное — окно, ряд табов статуса, длинные списки — брало + глобальное правило кита: 10px, серый бегунок `#414868`, без скругления. Теперь + полосы приложения тонкие, с бегунком-акцентом, скруглением в пилюлю и + **прозрачной дорожкой**: дорожка не должна вносить свою плашку на произвольную + поверхность (кит красит её в цвет той панели, где полоса стоит). Заданы и + `scrollbar-width`/`scrollbar-color` (Firefox и нативный путь Chromium), и + `::-webkit-scrollbar-*` (Safari и классические полосы) — так же, как в ките; + цвет берётся токеном `--gn-color-secondary`, чтобы темы кита перекрашивали и + полосы. У дровера приоритетнее правила кита — его вариант сохраняется. +- **Подвал карточки задачи — из слота `footer` кита** (0.76): кит рендерит его как + `