From e76b136e3748d64896baadd547e033c7f86f4553 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 17 Oct 2026 11:15:43 +0400 Subject: [PATCH 1/7] =?UTF-8?q?OpenSpec:=20hide-closed=20=E2=80=94=20?= =?UTF-8?q?=D1=81=D0=BA=D1=80=D1=8B=D1=82=D1=8C=20=D0=B7=D0=B0=D0=BA=D1=80?= =?UTF-8?q?=D1=8B=D1=82=D1=8B=D0=B5=20=D0=B2=20=D1=81=D0=BF=D0=B8=D1=81?= =?UTF-8?q?=D0=BA=D0=B5,=20=D0=BF=D1=80=D0=B8=D0=B3=D0=BB=D1=83=D1=88?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D0=BD=D0=B0=20=D0=B4=D0=BE=D1=81=D0=BA?= =?UTF-8?q?=D0=B5,=20=D1=83=D0=B1=D1=80=D0=B0=D1=82=D1=8C=20=D1=81=D1=82?= =?UTF-8?q?=D0=B0=D1=82=D1=83=D1=81=20=D0=BF=D0=BE=20=D1=84=D0=B0=D0=BA?= =?UTF-8?q?=D1=82=D1=83=20=D0=B8=D0=B7=20=D0=BF=D1=80=D0=B8=D0=BB=D0=BE?= =?UTF-8?q?=D0=B6=D0=B5=D0=BD=D0=B8=D1=8F=20(#57)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- openspec/changes/hide-closed/.openspec.yaml | 2 + openspec/changes/hide-closed/design.md | 88 ++++++++ openspec/changes/hide-closed/proposal.md | 35 ++++ .../hide-closed/specs/data-model/spec.md | 30 +++ .../changes/hide-closed/specs/hub-api/spec.md | 69 +++++++ .../changes/hide-closed/specs/web-app/spec.md | 192 ++++++++++++++++++ 6 files changed, 416 insertions(+) create mode 100644 openspec/changes/hide-closed/.openspec.yaml create mode 100644 openspec/changes/hide-closed/design.md create mode 100644 openspec/changes/hide-closed/proposal.md create mode 100644 openspec/changes/hide-closed/specs/data-model/spec.md create mode 100644 openspec/changes/hide-closed/specs/hub-api/spec.md create mode 100644 openspec/changes/hide-closed/specs/web-app/spec.md diff --git a/openspec/changes/hide-closed/.openspec.yaml b/openspec/changes/hide-closed/.openspec.yaml new file mode 100644 index 0000000..b699b88 --- /dev/null +++ b/openspec/changes/hide-closed/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-17 diff --git a/openspec/changes/hide-closed/design.md b/openspec/changes/hide-closed/design.md new file mode 100644 index 0000000..573e0b7 --- /dev/null +++ b/openspec/changes/hide-closed/design.md @@ -0,0 +1,88 @@ +## Context + +`main` 9f4bb75. Устройство, на которое опираются правки: + +- **Настройки** — `src/jira_hub/settings.py`: `DEFAULTS` (ключ → значение по умолчанию), `ensure_defaults` при открытии + базы добавляет недостающие ключи (`INSERT OR IGNORE`), `validate` проверяет тип по значению по умолчанию, + `AGENT_KEYS` — что уходит агенту. `GET /settings` отдаёт всё, кроме токенов. +- **Список на сервере** — `api.ISSUE_FILTERS` → `mirror.list_issues(conn, filters, limit, offset)`: `_FILTERS` + (точное совпадение по колонке) плюс отдельные ветки для `label`, `neighbor`, `q`. +- **Список в приложении** — `web/src/issues/IssuesPage.tsx`: фильтры из query-строки по `ISSUE_FILTER_NAMES` + (`web/src/api/queries.ts`), все уходят в `GET /issues` как есть; `SELECTS` — выпадающие списки, варианты — + `filterOptions` (`web/src/issues/options.ts`), «только соседи» — флажок `neighbor=1`. Порции по 50 (`useIssues`). +- **Доска** — `web/src/board/`: модель из `GET /board` / CSV / JSON → видимая выборка (`analysis/visible.ts`, `VisNode` + с `dim` — сосед, класс карточки `out`) → `BoardCanvas` → `Card.tsx` (класс `card`, `out`, `crit`, `focus`) и + `Edges.tsx` (`` на каждую связь). Настройки — `useSettings()` в `BoardPage.tsx`, словари — `dicts.ts`. + Golden-тесты сравнивают модель, выборку, критический путь и проблемы с `links.html@3931a88`. +- **Карточка** — `web/src/issues/IssueCard.tsx`: шапка с бейджем статуса по факту (`factClass` по `status_labels`), + «просрочено» — `isOverdue(due, fact, statusLabels, today)` (`card.ts`, R-2 #37). `Notes.tsx` — строки заметки, + `NoteForm.tsx` — форма, поле `fact_status` с подсказками из `status_labels`; сохраняются только изменённые поля. +- **Живые данные** (17.10.2026): закрытые статусы AURORA — «Готово» и «Снято»; `transitions_map` и `status_labels` + в настройках ключуются статусами по факту — это касается будущего полного удаления, не этой задачи. + +## Goals / Non-Goals + +**Goals:** в списке по умолчанию только незакрытые задачи, закрытые — одним флажком; на доске закрытые не отвлекают, но +связи через них видны; «статуса по факту» в веб-приложении нет. + +**Non-Goals:** см. proposal.md. + +## Decisions + +1. **Набор закрытых статусов — настройка хаба `closed_statuses`**, список строк, по умолчанию `["Готово", "Снято"]`. + Приложение по правилу «Справочники из настроек хаба» своих копий словарей не держит, а сервер нужен для фильтра + списка. В `DEFAULTS`, поэтому на живом хабе ключ появится при первом открытии базы после выкатки (`ensure_defaults`); + тип проверяет `validate` (список). В `AGENT_KEYS` не входит. Сравнение — точное совпадение строки статуса. +2. **Фильтр списка — на сервере:** `GET /issues?hide_closed=1` добавляет условие `i.status IS NULL OR i.status NOT IN + (…closed_statuses)` (задача без статуса — не закрыта). Клиентский фильтр поверх порций по 50 ломал бы «Показать + ещё» (порция могла бы прийти пустой). Значение `1` / `true` — включено, иное или отсутствие — выключено (как + `neighbor`). Пустой `closed_statuses` — фильтр ничего не скрывает. +3. **Адрес списка:** по умолчанию закрытые скрыты, в адресе ничего нет; снятый флажок «скрыть закрытые» — `closed=1`. + Приложение отправляет `hide_closed=1`, пока `closed` не `1`. `closed` — параметр приложения, в `GET /issues` не + уходит. Флажок — рядом с «только соседи». +4. **Явный выбор статуса сильнее флажка:** если в фильтре «Статус Jira» выбран статус из `closed_statuses`, + `hide_closed` не отправляется — иначе выборка всегда пуста. Флажок при этом остаётся как есть. +5. **Доска:** признак закрытости вычисляется при отрисовке, а не в модели — модель, выборка, критический путь и + проблемы остаются как у `links.html` (golden-тесты не трогаются). Карточка получает класс `closed` + (`opacity: .4`), под указателем и с фокусом внутри — полная яркость (`.card.closed:hover`, `.card.closed:focus-within`; + признак `VisNode.focus` нигде не включается, выделения карточек на доске нет). Стрелка, у которой хотя бы один конец — + закрытая задача, — класс/атрибут с той же прозрачностью. Сосед (`out`) и закрытая — признаки независимые, вместе + — оба стиля. Набор закрытых — из `useSettings()`; карточки из CSV и JSON приглушаются по тому же правилу (статус у + них есть). +6. **Нет `closed_statuses` в настройках** (хаб старее приложения) — ничего не считается закрытым: список без фильтра, + доска без приглушения, ошибки «нет словаря» нет. Хаб и приложение выкатываются вместе, это окно короткое. +7. **«Просрочено» в карточке** — по статусу Jira: срок раньше сегодня и статус не из `closed_statuses` (было — по + классу статуса по факту, R-2 #37). Без статуса по факту прежнее правило не на чем считать. +8. **«Статус по факту» из приложения:** убрать из `ISSUE_FILTER_NAMES` и `SELECTS`, колонку таблицы, вариант из + `filterOptions`, бейдж в шапке карточки (`factClass`, стили `.badge.fact*`), строку в `Notes.tsx`, поле и подсказки + в `NoteForm.tsx`. Тип `NoteFields` / `NOTE_FIELDS` приложения — без `fact_status`; так форма не может отправить его + и в `PUT` значение не трогается. Тип ответа API (`IssueRow.fact_status`, `Note.fact_status`) — поле остаётся + необязательным, приложение его не читает. Старый адрес с `fact_status=` — параметр игнорируется (нет в + `ISSUE_FILTER_NAMES`). + +## Risks / Trade-offs + +- Новый статус Jira закрытого смысла (например, «Отклонено») не будет скрыт, пока его не добавят в `closed_statuses` + — осознанно: владелец назвал ровно два статуса. +- Пока хаб не перезапущен после выкатки, сборка приложения новее хаба: `hide_closed` хаб старой версии игнорирует, и + список покажет закрытые — безвредно. + +## Decisions made without discussion + +- 2026-10-17 — **набор закрытых статусов в настройке хаба `closed_statuses`**, а не константой в коде. Обоснование: + правило «Справочники из настроек хаба», фильтр нужен и серверу, и приложению. Цена ошибки: лишний ключ настроек; + убрать — правка `DEFAULTS` и двух мест чтения. +- 2026-10-17 — **фильтр `hide_closed` на сервере**, а не в браузере. Обоснование: порции по 50. Цена ошибки: один + параметр API; поведение MCP не меняется. +- 2026-10-17 — **адрес: `closed=1` — показать закрытые; по умолчанию параметра нет.** Обоснование: умолчание владельца + «скрыты», адрес без лишних параметров. Цена ошибки: переименование параметра. +- 2026-10-17 — **выбранный в «Статус Jira» закрытый статус отменяет скрытие.** Обоснование: иначе выборка всегда пуста. + Цена ошибки: поведение одного сочетания фильтров. +- 2026-10-17 — **приглушение: `opacity .4`, полная яркость под указателем и с фокусом клавиатуры внутри; стрелки — если закрыт хотя бы один + конец.** Обоснование: владелец подтвердил «связи тоже бледнеют»; степень прозрачности — на глаз, отличается от + соседа (`.7`, пунктир). Цена ошибки: правка CSS. +- 2026-10-17 — **без `closed_statuses` — ничего не закрыто, без сообщения об ошибке.** Обоснование: это не словарь + подписей, без него страница работает как раньше. Цена ошибки: нет. +- 2026-10-17 — **«просрочено» — по статусу Jira из `closed_statuses`.** Обоснование: статус по факту из приложения + убран. Цена ошибки: пометка «просрочено» у задачи, закрытой по факту, но не в Jira, — теперь видна (это и есть + статус Jira). diff --git a/openspec/changes/hide-closed/proposal.md b/openspec/changes/hide-closed/proposal.md new file mode 100644 index 0000000..a214257 --- /dev/null +++ b/openspec/changes/hide-closed/proposal.md @@ -0,0 +1,35 @@ +## Why + +В списке «Задачи» и на доске видны в том числе закрытые задачи: они занимают место и мешают видеть то, что ещё в +работе. Кроме того, «статус по факту» из служебных заметок вносит шум: в списке и карточке рядом два статуса. + +Решения владельца 17.10.2026 (brainstorming, jira-hub#57): +- закрытая задача — та, у которой **статус Jira** «Готово» или «Снято»; других признаков закрытости нет; +- список: закрытые можно **скрыть** — переключатель в фильтрах, по умолчанию они скрыты, выбор хранится в адресе, как + остальные фильтры; +- доска: закрытые **приглушены** — карточка и её связи полупрозрачные, всегда, без переключателя; карточка под + указателем или с фокусом клавиатуры внутри — в полную яркость (выделения карточек на доске нет); +- «статус по факту» убрать **из веб-приложения** (колонка и фильтр списка, шапка карточки, заметки в карточке и форма); + в хабе (база, API, MCP, `status_labels`, `transitions_map`) пока остаётся; полностью от него избавиться — позже, + отдельной задачей. + +## What Changes + +- **Настройки хаба:** новый ключ `closed_statuses` — список имён статусов Jira, считающихся закрытыми, по умолчанию + `["Готово", "Снято"]`; агенту не передаётся. +- **API хаба:** `GET /issues` принимает фильтр `hide_closed=1` — без задач, у которых `status` из `closed_statuses`. +- **Веб-приложение, список «Задачи»:** флажок «скрыть закрытые», по умолчанию включён; выключенный — `closed=1` в + адресе; колонки и фильтра «Статус по факту» нет. +- **Веб-приложение, доска:** карточки задач со статусом из `closed_statuses` и стрелки, у которых хотя бы один конец — + такая задача, приглушены; модель, видимая выборка, критический путь и проблемы не меняются. +- **Веб-приложение, карточка задачи:** в шапке нет статуса по факту; «просрочено» — по статусу Jira (срок прошёл и + статус не из `closed_statuses`); в «Служебных заметках» и их форме нет поля «Статус по факту» (сохранение его не + трогает). + +## Non-Goals + +- Удаление `fact_status` из базы, API `GET /issues` (фильтр и поле), MCP `issues` (колонка), `status_labels`, + `transitions_map`, импорта — отдельная задача после этой. +- Переключатель приглушения на доске, скрытие закрытых с доски. +- Правка `closed_statuses` из веб-приложения (меняется через `PUT /settings` / MCP `settings_set`). +- MCP `issues` не получает параметр `hide_closed`. diff --git a/openspec/changes/hide-closed/specs/data-model/spec.md b/openspec/changes/hide-closed/specs/data-model/spec.md new file mode 100644 index 0000000..7661fa5 --- /dev/null +++ b/openspec/changes/hide-closed/specs/data-model/spec.md @@ -0,0 +1,30 @@ +## MODIFIED Requirements + +### Requirement: Настройки +Таблица `setting` SHALL хранить парами ключ–JSON: `base_jql`, `field_ids`, `issue_types`, `priority_map`, `link_types`, +`transitions_map`, `pacing`, `board_id`, `tokens` (агентский и клиентский), `settings_version` (увеличивается при любой +правке), `backup_keep`, `auto_refresh_minutes`, `relay_dir` (абсолютный путь папки реле или `null`, по умолчанию `null`; +относительный путь и не-строка при записи — 422; агенту не передаётся), `closed_statuses` (список имён статусов Jira, считающихся закрытыми, по умолчанию +`["Готово", "Снято"]`; не-список при записи — 422; агенту не передаётся; на существующей базе появляется со значением +по умолчанию при открытии хаба). Спринты агент читает с доски `board_id`, +отдельной настройки имён спринтов нет. Начальные значения SHALL импортироваться из `admin/jira-sync/config.example.json` +командой `jira-hub init`; пароль и логин Jira в настройках MUST NOT присутствовать. `issue_types` — объект +`{task, epic, bug}`, у каждого `{name, id}` (`id` может быть `null`); `init` берёт их из ключей конфига +`issue_type_<тип>` и `issue_type_<тип>_id`; `bug` попадает в `issue_types`, только если в конфиге есть хотя бы +один из ключей `issue_type_bug`, `issue_type_bug_id` (`task` и `epic` — как раньше, всегда). + +#### Scenario: Правка настроек поднимает версию +- **WHEN** через API изменён `pacing` +- **THEN** `settings_version` увеличен, следующий `claim` отдаёт агенту новую версию + +#### Scenario: Конфиг без ключей ошибки +- **WHEN** `jira-hub init --config` с `issue_type_task`, `issue_type_epic`, без `issue_type_bug` +- **THEN** `issue_types` содержит `task` и `epic`, `bug` нет; постановка `ensure_issue` с `issue_type = bug` — 422 + +#### Scenario: Неверный relay_dir +- **WHEN** `PUT /settings` с `relay_dir = "rshb/jira-bridge"` +- **THEN** 422, значение не записано, `settings_version` не изменился + +#### Scenario: Закрытые статусы по умолчанию +- **WHEN** хаб открыл базу, в которой нет ключа `closed_statuses` +- **THEN** `GET /settings` отдаёт `closed_statuses = ["Готово", "Снято"]`; `claim` агенту этого ключа не передаёт diff --git a/openspec/changes/hide-closed/specs/hub-api/spec.md b/openspec/changes/hide-closed/specs/hub-api/spec.md new file mode 100644 index 0000000..dd642c2 --- /dev/null +++ b/openspec/changes/hide-closed/specs/hub-api/spec.md @@ -0,0 +1,69 @@ +## MODIFIED Requirements + +### Requirement: Маршруты интерфейсов +Хаб SHALL предоставлять интерфейсам (MCP, позже просмотрщик) следующие маршруты под клиентским токеном: +- `GET /issues` с фильтрами `sprint`, `assignee`, `work_kind`, `fact_status`, `status`, `label`, `epic`, `neighbor`, + `q` (подстрока темы), `hide_closed` (`1` / `true` — без задач, чей `status` входит в настройку `closed_statuses`; + задача без статуса не закрыта), `limit`/`offset` — строки зеркала (все поля `issue`) с `fact_status` и `work_kind` из заметок; + сокращение до карточки — дело интерфейса. +- `GET /issues/{key}` — задача целиком: поля, `epic_summary` (тема эпика из зеркала или `null`), связи в обе стороны + (`links_out: [{target_key, link_type, summary, status}]`, `links_in: [{source_key, link_type, summary, status}]`, где + `summary` и `status` — связанной задачи из зеркала или `null`, если её там нет), заметки, последние 10 команд по ней, + `comments: [{comment_id, author, created, updated, body}]` — все комментарии, новые сверху (по `created`, затем + `comment_id`, по убыванию). +- `PUT /issues/{key}/note` — заметки (частичное обновление, `updated_by` из тела). +- `POST /batches` `{title, author, commands: [...]}` → `{id, estimate_seconds}`; 422 при неизвестном типе, неверных + параметрах (в том числе неверного типа значения), временном ключе без `ensure_issue`, двух и более `ensure_issue` с + одинаковой `summary` (сравнение после обрезки пробелов по краям; в ответе перечислены повторяющиеся темы), а также + по проверкам постановки из `commands` (тип `bug` без настройки, файл вложения не загружен). +- `POST /files` `{content}` (`content` — base64 содержимого файла) → `{file, size}`, где `file` — sha256 содержимого + (64 hex-символа, нижний регистр); см. «Хранилище файлов вложений». +- `GET /batches?limit=`, `GET /batches/{id}` (с командами), `POST /batches/{id}/cancel` — отменяет `queued` команды + пакета; ответ — вид пакета с полем `still_claimed`: список `{id, type}` команд пакета в `claimed` (они будут выполнены + агентом; остановить — отмена команды с `force`). +- `POST /commands/{id}/retry` — новая команда с теми же параметрами в новом пакете «Повтор команды N». +- `POST /commands/{id}/cancel` — только для `queued`; для `claimed` — 409, если не передан `force: true`. +- `POST /refresh` `{keys | jql | full: true}` → пакет из одного `refresh`. +- `GET /agent`, `GET /sync-runs?limit=`, `GET /settings`, `PUT /settings` (частично; поднимает `settings_version`; + ответ — все настройки после записи, как у `GET /settings`, включая `settings_version`). + +#### Scenario: Фильтр по спринту +- **WHEN** `GET /issues?sprint=Спринт 2` +- **THEN** только задачи с таким `sprint`, отсортированы по сроку, затем по ключу + +#### Scenario: Отмена взятой команды +- **WHEN** `POST /commands/15/cancel` без `force`, команда `claimed` +- **THEN** 409 с `claimed_at`; с `force: true` — `cancelled`, следующий отчёт агента по ней принимается, статус не меняется + +#### Scenario: Поздний отчёт по отменённому созданию +- **WHEN** `ensure_issue {temp_key: new:7, …}` отменена с `force: true`, затем агент прислал `done` с `result.key = AURORA-1620` +- **THEN** команда `cancelled`, в `result` — `AURORA-1620`; заметка `new:7` перенесена на `AURORA-1620` + +#### Scenario: Дубль темы в пакете +- **WHEN** `POST /batches` с двумя `ensure_issue`, у обеих `summary = "Шахматка: комментарии"` +- **THEN** 422 с темой «Шахматка: комментарии», пакет не создан + +#### Scenario: Тема не строка +- **WHEN** `POST /batches` с `ensure_issue {temp_key: new:1, summary: 42, …}` +- **THEN** 422 с указанием параметра `summary`, пакет не создан + +#### Scenario: Отмена пакета с командой у агента +- **WHEN** в пакете 9 команда 30 `claimed`, команды 31 и 32 `queued`; `POST /batches/9/cancel` +- **THEN** 31 и 32 `cancelled`; в ответе `still_claimed = [{id: 30, type: …}]`; 30 остаётся `claimed` + +#### Scenario: Ответ на запись настроек +- **WHEN** `PUT /settings {relay_dir: "/Volumes/externalSSD/utm/rshb/jira-bridge"}` +- **THEN** 200; ответ содержит `relay_dir` с записанным значением, новую `settings_version` и остальные настройки; + токенов в ответе нет + +#### Scenario: Карточка с комментариями и связями +- **WHEN** у AURORA-9001 два комментария (14.10 и 15.10), исходящая связь на AURORA-9002 («API свода», «Новая») и на + AURORA-1, которой нет в зеркале; эпик AURORA-9100 «Тестовый эпик» +- **THEN** `GET /issues/AURORA-9001` отдаёт `comments` с комментарием от 15.10 первым, у связи на AURORA-9002 — + `summary = "API свода"`, `status = "Новая"`, у связи на AURORA-1 — `summary = null`, `status = null`; + `epic_summary = "Тестовый эпик"` + +#### Scenario: Без закрытых +- **WHEN** `closed_statuses = ["Готово", "Снято"]`, в зеркале задачи в статусах «В работе», «Готово», «Снято» и без + статуса; `GET /issues?hide_closed=1` +- **THEN** в ответе задачи «В работе» и без статуса; без `hide_closed` — все четыре diff --git a/openspec/changes/hide-closed/specs/web-app/spec.md b/openspec/changes/hide-closed/specs/web-app/spec.md new file mode 100644 index 0000000..1c58b56 --- /dev/null +++ b/openspec/changes/hide-closed/specs/web-app/spec.md @@ -0,0 +1,192 @@ +## MODIFIED Requirements + +### Requirement: Справочники из настроек хаба +Приложение SHALL брать перевод меток видов работ (`kind_labels`) и типов связей Jira (`link_types`) из `GET /settings` +и набор закрытых статусов Jira (`closed_statuses`) из `GET /settings` +и не содержать собственных копий этих словарей. Если нужного словаря в настройках нет, страница SHALL сообщать, какого +словаря не хватает, вместо пустой доски или списка без подписей; исключение — `closed_statuses`: без него ни одна +задача не считается закрытой, сообщения нет. + +#### Scenario: Новый тип связи в настройках +- **WHEN** в `link_types` настроек добавлен тип и в зеркале есть связь этого типа +- **THEN** после перезагрузки страницы доска показывает связь этого типа без изменения кода приложения + +### Requirement: Доска связей +Страница `/app/board` SHALL воспроизводить поведение доски связей хаба (`links.html` ревизии 3931a88) без изменений, +кроме перечисленных в этом требовании и в требовании «Источник данных доски»: построение модели из `GET /board`, CSV +фильтра Jira и JSON доски (`arm-ka-links/1`); дорожки по эпикам со свёрткой и перестановкой, служебные дорожки внизу; +карточки с перетаскиванием, растяжкой, размером по содержимому и привязкой к сетке; стрелки связей с выравниванием; +фильтры по спринтам и исполнителям; легенда со скрытием видов работ и типов связей; «+ соседи по связям», «⇄ передачи», +«скрыть без связей», «⚑ критический путь», «▥ по датам» с колонками дат и их растяжкой; ящик «Проблемы»; таблица +связей; экспорт и импорт JSON; тема; «Сбросить раскладку». Раскладка, дорожки, фильтры и тема SHALL храниться в +`localStorage` под теми же ключами `graph-<имя>…`, что у `links.html`, с той же миграцией старых ключей. +Результат построения модели, видимой выборки, критического пути и проблем SHALL совпадать с результатом `links.html` +на одних и тех же данных и фильтрах. У карточки задачи (не у заглушки) SHALL быть значок «Открыть карточку», +открывающий `/app/issues/{key}` окном поверх доски; ключ карточки по-прежнему открывает задачу в Jira. Пока окно открыто +и после его закрытия доска SHALL сохранять прокрутку, раскладку, фильтры и строку состояния пакета. Действия «Сменить токен» в меню +доски нет — оно в верхней панели приложения. Ещё два отличия от `links.html` (R-10, R-18): двойной щелчок по уголку +карточки меряет высоту по содержимому при новой ширине, а не при прежней; указатель при перетаскивании и растяжке +захватывается на нажатом элементе, а не на карточке, поэтому двойной щелчок по уголку сбрасывает размер. Загрузка файла CSV/JSON доступна только после входа, с экрана входа её нет. +Карточка задачи, статус Jira которой входит в `closed_statuses`, SHALL быть приглушена (полупрозрачна), под +указателем и с фокусом клавиатуры внутри карточки — в полную яркость; стрелка связи, у которой хотя бы один конец — такая задача, SHALL быть приглушена так же. +Приглушение — только вид: модель, видимая выборка, критический путь, проблемы, раскладка и экспорт JSON от него не +зависят; переключателя нет; правило одно для данных хаба, CSV и JSON. +При возврате на `/app/board` с другой страницы приложения в той же вкладке доска SHALL восстанавливать прежнюю +прокрутку; после перезагрузки страницы доска открывается сверху. + +#### Scenario: Раскладка переживает переход +- **WHEN** раскладка карточек сохранена страницей `links.html`, открыта `/app/board` с теми же данными +- **THEN** карточки стоят на тех же местах, дорожки в том же порядке и свёртке, фильтры те же + +#### Scenario: Совпадение со старой доской +- **WHEN** на одной фикстуре `GET /board` и одном наборе фильтров построены модель, видимая выборка, критический путь + и проблемы старой страницей и приложением +- **THEN** результаты совпадают + +#### Scenario: Открыть карточку +- **WHEN** на карточке AURORA-1620 нажат значок «Открыть карточку» +- **THEN** адрес `/app/issues/AURORA-1620`, карточка задачи открыта окном, под ним доска + +#### Scenario: Возврат с карточки +- **WHEN** доска прокручена вниз, нажат значок «Открыть карточку», затем окно закрыто +- **THEN** адрес `/app/board`, доска показана с той же прокруткой и раскладкой, без повторной загрузки `GET /board` + +#### Scenario: Возврат из другого раздела +- **WHEN** доска прокручена вниз, выбран раздел «Задачи», затем раздел «Доска» +- **THEN** доска показана с той же прокруткой + +#### Scenario: Закрытые на доске приглушены +- **WHEN** на доске AURORA-1620 «В работе», AURORA-1621 «Готово», AURORA-1622 «Снято», связь AURORA-1620 → AURORA-1621 +- **THEN** карточки AURORA-1621 и AURORA-1622 и стрелка AURORA-1620 → AURORA-1621 приглушены, AURORA-1620 — нет; + под указателем AURORA-1621 — в полную яркость; критический путь и ящик «Проблемы» те же, что без приглушения + +### Requirement: Список задач +Страница `/app/issues` SHALL показывать задачи зеркала из `GET /issues` таблицей: ключ (открывает карточку задачи окном поверх списка), тема, +тип, статус Jira, исполнитель, спринт, срок (`planned_end`, иначе `duedate`), эпик (имя эпика, иначе ключ), вид работы, +признак «сосед»; статуса по факту в списке нет. Фильтры SHALL соответствовать параметрам `GET /issues` (спринт, исполнитель, вид +работы, статус Jira, метка, эпик, только соседи, поиск по ключу или теме, скрыть закрытые), храниться в query-строке +адреса и применяться сервером; варианты выпадающих списков SHALL браться из снимка `GET /board`; значение фильтра из адреса, которого нет среди +вариантов, SHALL показываться в списке с пометкой «(нет на доске)». Флажок «скрыть закрытые» SHALL +быть включён по умолчанию (в адресе ничего), снятый — `closed=1` в адресе; включённый — запрос с `hide_closed=1`, +кроме случая, когда в фильтре «Статус Jira» выбран статус из `closed_statuses`. Параметр `fact_status` в адресе +игнорируется. Задачи SHALL +загружаться порциями по 50 строк; «Показать ещё» SHALL быть доступна, пока последняя порция полная. + +#### Scenario: Фильтр в адресе +- **WHEN** выбран спринт «AWPCA_Спринт 2» и исполнитель +- **THEN** адрес содержит `sprint=` и `assignee=`, таблица — результат `GET /issues` с этими параметрами; открытие этого + адреса в новой вкладке показывает ту же выборку + +#### Scenario: Конец списка +- **WHEN** вторая порция вернула 17 задач +- **THEN** в таблице 67 задач, «Показать ещё» не показана + +#### Scenario: Фильтр по закрытому спринту +- **WHEN** открыт адрес `/app/issues?sprint=AWPCA_Спринт 0`, а в снимке доски такого спринта нет +- **THEN** выпадающий список «Спринт» показывает «AWPCA_Спринт 0 (нет на доске)» выбранным, таблица — по этому фильтру + +#### Scenario: Карточка поверх списка +- **WHEN** список отфильтрован по спринту, прокручен и догружен «Показать ещё», открыта карточка задачи и окно закрыто +- **THEN** адрес списка с тем же фильтром, в таблице те же строки, прокрутка та же + +#### Scenario: Закрытые скрыты по умолчанию +- **WHEN** открыт `/app/issues`, в зеркале задачи в статусах «В работе», «Готово», «Снято» +- **THEN** флажок «скрыть закрытые» включён, запрос `GET /issues` с `hide_closed=1`, в таблице только «В работе»; после + снятия флажка адрес содержит `closed=1`, в таблице все три + +#### Scenario: Выбран закрытый статус +- **WHEN** флажок «скрыть закрытые» включён и в «Статус Jira» выбрано «Готово» +- **THEN** запрос `GET /issues?status=Готово` без `hide_closed`, в таблице задачи в статусе «Готово» + +### Requirement: Карточка задачи +Адрес `/app/issues/{key}` SHALL открывать карточку задачи из `GET /issues/{key}` модальным окном поверх страницы, с +которой её открыли (список «Задачи» или доска); страница под окном SHALL оставаться смонтированной и сохранять фильтры, +загруженные строки, прокрутку и раскладку. Перезагрузка страницы с открытым окном SHALL сохранять страницу под окном. Открытый напрямую +адрес (новая вкладка, закладка) SHALL +показывать окно поверх списка задач без фильтров. Окно SHALL закрываться кнопкой ✕, клавишей Esc и щелчком по фону (нажатие и отпускание — на фоне; выделение текста, +отпущенное за краем окна, окно не закрывает) и +возвращать на страницу, с которой его открыли, даже после переходов по связям внутри окна; «назад» браузера после +закрытия MUST NOT открывать карточку снова. Переход по связанной задаче SHALL менять содержимое окна и адрес, «назад» +браузера — возвращать к прежней карточке. Пока окно открыто, фокус SHALL оставаться в окне, страница под ним не +прокручивается. + +Содержимое окна: +- шапка: ключ, статус Jira, «Открыть в Jira», ✕, тема; строка + «исполнитель · срок · спринт · эпик» без пустых частей, срок — `planned_end`, иначе `duedate`, с пометкой «просрочено», + если он раньше сегодняшней даты и статус Jira не входит в `closed_statuses`; эпик — темой + (`epic_summary`), иначе ключом; +- «Связи» по группам в порядке: «Зависит от» (входящие связи логических типов `блокирует начало` и `блокирует + завершение` из `link_types`, с меткой «начало» / «завершение»), «Блокирует» (исходящие тех же типов, с теми же метками), + «Связана с» (тип `связанные` в обе стороны), «Прочие» (тип Jira вне `link_types`: имя типа и направление «→» / «←»); + в строке — ключ (открывает связанную задачу в окне), тема и статус Jira, а если задачи нет в зеркале — ключ и «нет в + зеркале»; пустые группы не показываются, без связей — «связей нет»; +- «Описание» — текст, отрендеренный по требованию «Разметка Jira в карточке», с кнопкой «исходник» / «текст» в + заголовке раздела; длиннее 8 строк — свёрнуто с кнопкой «развернуть» / «свернуть» в обоих режимах; без описания + раздела нет; +- «Комментарии (N)» — новые сверху, автор, время, кнопка «исходник» / «текст» у каждого комментария, текст, + отрендеренный по требованию «Разметка Jira в карточке»; кнопка «Скрыть» / «Показать» сворачивает раздел, выбор SHALL + запоминаться в браузере для всех карточек; без комментариев — «комментариев нет»; +- «Служебные заметки» (требование «Служебные заметки»); +- «Прочие поля» — непустые из: тип, приоритет, автор, метки, плановое начало, обновлена в Jira, сосед по связям; +- «Команды моста (N)» — 10 последних команд по задаче (id, пакет, тип, статус, время завершения, ошибка), раздел свёрнут + при открытии; если команд не было — текст «команд по задаче не было». +На ширине от 900 px «Связи», «Описание», «Комментарии» — в левой колонке, заметки, прочие поля и команды — в правой; +уже — одна колонка: заметки, связи, описание, комментарии, прочие поля, команды. Если задачи нет в зеркале (404), окно +SHALL сообщать об этом с кнопкой «Закрыть»; хаб не отвечает — сообщение и «Повторить», ранее загруженные данные +остаются. + +#### Scenario: Связанная задача +- **WHEN** у AURORA-1620 исходящая связь `ASUPR_Блокер_Начала` на AURORA-1621 «Расчёт свода» в статусе «В работе» +- **THEN** в карточке в группе «Блокирует» строка «начало · AURORA-1621 · Расчёт свода · В работе»; щелчок по ключу + меняет содержимое окна на AURORA-1621, адрес — `/app/issues/AURORA-1621`; «назад» браузера возвращает AURORA-1620 + +#### Scenario: Входящий блокер +- **WHEN** у AURORA-1620 входящая связь `ASUPR_Блокер_Завершения` от AURORA-1500 +- **THEN** строка с AURORA-1500 и меткой «завершение» — в группе «Зависит от» + +#### Scenario: Задача без команд +- **WHEN** по AURORA-1620 не было команд +- **THEN** в разделе «Команды моста» — текст «команд по задаче не было», таблицы нет + +#### Scenario: Закрытие после переходов +- **WHEN** со списка «Задачи» открыта AURORA-1620, в окне — переход по связи на AURORA-1621, нажата Esc +- **THEN** окно закрыто, адрес — адрес списка с прежними фильтрами; «назад» браузера не открывает карточку + +#### Scenario: Прямой адрес +- **WHEN** в новой вкладке открыт `/app/issues/AURORA-1620`, затем нажат ✕ +- **THEN** под окном был список задач без фильтров; после закрытия адрес `/app/issues`, «назад» не открывает карточку + +#### Scenario: Скрытые комментарии +- **WHEN** в карточке нажато «Скрыть» у комментариев, затем страница перезагружена и открыта другая карточка +- **THEN** комментарии в ней скрыты, видна кнопка «Показать» и число комментариев + +#### Scenario: Просрочка закрытой задачи +- **WHEN** у AURORA-1620 срок 2026-10-01, сегодня 2026-10-17, статус Jira «Готово» +- **THEN** в строке под темой «срок 01.10.2026» без пометки «просрочено»; при статусе «В работе» — с пометкой + +### Requirement: Служебные заметки +Карточка задачи SHALL показывать служебные заметки в режиме чтения: «Вид работы», «Сделано», +«Осталось», «Заметка владельца» (пустые — «—») и время и автор последнего изменения; кнопка «Править» SHALL открывать +форму: «вид работы» — выбор из значений `kind_labels` настроек (и текущего значения, если его там нет, и пустого), +«сделано», «осталось», «заметка владельца» — +многострочный текст; «Отмена» SHALL возвращать режим чтения, при несохранённых правках — после подтверждения. +Сохранение SHALL отправлять `PUT /issues/{key}/note` только с изменёнными полями и `updated_by: "web"`; очищенное поле +отправляется как `null`. После успеха SHALL показываться режим чтения с сохранённой заметкой; список задач под окном SHALL +показывать новый «вид работы» без «Перечитать», доска под окном SHALL перечитать снимок +(`GET /board`) без потери раскладки и прокрутки; при 422 — текст ошибки у формы без +потери введённого. Закрытие окна, переход по связи и уход в другой раздел с несохранёнными правками SHALL требовать +подтверждения. Статуса по факту в заметках приложения нет: форма его не показывает и не отправляет, сохранённое в хабе значение не +меняется. Заметки пишутся только в хаб, в Jira приложение ничего не отправляет. + +#### Scenario: Только изменённые поля +- **WHEN** в заметке нажато «Править», изменено только «осталось» и нажато «Сохранить» +- **THEN** тело `PUT /issues/{key}/note` — `{rest_text: "…", updated_by: "web"}`; карточка снова в режиме чтения с новым + «Осталось» + +#### Scenario: Уход с несохранёнными правками +- **WHEN** в форме заметки есть несохранённая правка и нажата Esc +- **THEN** приложение спрашивает подтверждение; при отказе окно открыто, форма с введённым текстом + +#### Scenario: Заметка видна на доске +- **WHEN** карточка AURORA-9002 открыта с доски, вид работы изменён на «тестирование» и сохранён +- **THEN** доска под окном остаётся на месте и перезапрашивается (`GET /board` отдаёт `work_kind` «тестирование»); окно закрывается без потери раскладки и прокрутки доски From 43fba6954aea09b40196608d097690d13a52f7e1 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 17 Oct 2026 11:24:04 +0400 Subject: [PATCH 2/7] =?UTF-8?q?=D0=9F=D0=BB=D0=B0=D0=BD:=20hide-closed=20(?= =?UTF-8?q?#57)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../plans/2026-10-17-hide-closed.md | 305 ++++++++++++++++++ 1 file changed, 305 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-17-hide-closed.md diff --git a/docs/superpowers/plans/2026-10-17-hide-closed.md b/docs/superpowers/plans/2026-10-17-hide-closed.md new file mode 100644 index 0000000..9debd4f --- /dev/null +++ b/docs/superpowers/plans/2026-10-17-hide-closed.md @@ -0,0 +1,305 @@ +# Закрытые задачи: скрыть в списке, приглушить на доске — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** закрытые задачи (статус Jira из настройки `closed_statuses`, по умолчанию «Готово», «Снято») скрыты в списке +по умолчанию и приглушены на доске; «статуса по факту» в веб-приложении нет. + +**Architecture:** хаб получает настройку `closed_statuses` и фильтр `GET /issues?hide_closed=1` (фильтр на сервере — +список порциями по 50). Приложение берёт набор из `GET /settings` одним помощником `web/src/api/closed.ts`; список +шлёт `hide_closed`, доска вешает классы при отрисовке (модель и golden-эталоны не меняются), карточка считает +«просрочено» по статусу Jira. Поле `fact_status` уходит из кода приложения, в API и базе остаётся. + +**Tech Stack:** Python 3.13 (stdlib http, SQLite), pytest; React 19 + TypeScript + Vite, TanStack Query, Vitest, +Testing Library, Playwright. + +**Spec:** `openspec/changes/hide-closed/` (proposal.md, design.md, specs/web-app, specs/hub-api, specs/data-model) + +## Global Constraints + +- Закрытые статусы по умолчанию — ровно `["Готово", "Снято"]`; сравнение — точное совпадение строки статуса. +- `closed_statuses` не входит в `AGENT_KEYS`. +- Задача без статуса (`NULL` / `""`) — не закрыта. +- Без `closed_statuses` в настройках (или не список) — ничего не закрыто, сообщения «нет словаря» нет. +- Адрес списка: по умолчанию параметра нет (закрытые скрыты); снятый флажок — `closed=1`; `closed` в `GET /issues` не + уходит; `fact_status` из адреса игнорируется. +- Выбранный в «Статус Jira» статус из `closed_statuses` — `hide_closed` не отправляется. +- Доска: модель, видимая выборка, критический путь, проблемы, раскладка, экспорт JSON от приглушения не зависят — + golden-эталоны `web/src/board/__fixtures__/*.expected.json` не перегенерировать. +- `dangerouslySetInnerHTML` не появляется; новых зависимостей нет. +- Язык комментариев, коммитов, подписей — русский; коммиты заканчиваются строкой + `Co-Authored-By: Claude Opus 5.5 `. +- Зелёным считается только весь набор: `make check` (pytest + lint и тесты фронта) и `cd web && npm run e2e`. + +## Review Focus + +1. Список, когда настройки ещё не загружены, а в адресе `status=Готово`: таблица не должна навсегда остаться пустой — + после загрузки настроек запрос уходит без `hide_closed` (тест в Task 2). +2. Карточка-сосед, она же закрытая (`out` + `closed`): должна быть прозрачнее соседа, а не `.7` — порядок правил CSS + (тест класса в Task 4, правило `.card.closed` ниже `.card.out` в `board.css`). +3. Сохранение заметки у задачи с непустым `fact_status`: `PUT` не должен отправить `fact_status: null` (тест в Task 3). +4. `PUT /settings {closed_statuses: "Готово"}` (строка вместо списка) — 422, а не поломка фильтра (тест в Task 1). +5. Пустой `closed_statuses = []` — `hide_closed=1` ничего не скрывает и не падает на `NOT IN ()` (тест в Task 1). + +--- + +### Task 1: Хаб — настройка `closed_statuses` и фильтр `hide_closed` + +**Files:** +- Modify: `src/jira_hub/settings.py` (`DEFAULTS`) +- Modify: `src/jira_hub/mirror.py` (`list_issues`) +- Modify: `src/jira_hub/api.py` (`ISSUE_FILTERS`, `h_issues`) +- Modify: `README.md:153` (строка `GET /issues` — добавить `hide_closed`), `ARCHITECTURE.md:50` (словари из + `GET /settings` — добавить `closed_statuses`, убрать `status_labels`: приложение его больше не читает) +- Test: `tests/test_settings.py`, `tests/test_mirror.py`, `tests/test_api.py` + +**Interfaces:** +- Produces: настройка `closed_statuses: list[str]` в `GET /settings`; `GET /issues?hide_closed=1`; + `mirror.list_issues(conn, filters, limit, offset, closed_statuses=())` — новый необязательный параметр. + +- [ ] **Step 1: Write the failing tests** + +`tests/test_settings.py`: +```python +def test_closed_statuses_default(conn): + assert settings.get(conn, "closed_statuses") == ["Готово", "Снято"] + assert "closed_statuses" not in settings.AGENT_KEYS + assert "closed_statuses" not in settings.for_agent(conn) + +def test_closed_statuses_must_be_list(): + with pytest.raises(ValueError): + settings.validate("closed_statuses", "Готово") + settings.validate("closed_statuses", ["Готово"]) +``` +(`conn` — фикстура модуля, уже вызывает `ensure_defaults`; если нет — вызвать.) + +`tests/test_mirror.py`: +```python +def test_list_issues_hide_closed(conn): + mirror.apply_state(conn, [issue("A-1", status="В работе"), issue("A-2", status="Готово"), + issue("A-3", status="Снято"), issue("A-4", status=None)], []) + closed = ["Готово", "Снято"] + keys = lambda f, c=closed: sorted(i["key"] for i in mirror.list_issues(conn, f, closed_statuses=c)) + assert keys({"hide_closed": "1"}) == ["A-1", "A-4"] + assert keys({"hide_closed": "true"}) == ["A-1", "A-4"] + assert keys({}) == ["A-1", "A-2", "A-3", "A-4"] + assert keys({"hide_closed": "0"}) == ["A-1", "A-2", "A-3", "A-4"] + assert keys({"hide_closed": "1"}, []) == ["A-1", "A-2", "A-3", "A-4"] # пустой набор — ничего не скрыто + assert keys({"hide_closed": "1", "status": "Готово"}) == [] # сервер не отменяет — это делает приложение +``` +(если помощник `issue()` не принимает `status` — передать его так же, как `sprint` в соседнем тесте.) + +`tests/test_api.py` — сценарий «Без закрытых» из `specs/hub-api`: четыре задачи как выше, `GET /issues?hide_closed=1` +→ ключи `A-1`, `A-4`; без параметра — все четыре; после `PUT /settings {closed_statuses: ["Готово"]}` — +`hide_closed=1` отдаёт `A-1`, `A-3`, `A-4`. И `PUT /settings {closed_statuses: "Готово"}` → 422, значение не +изменилось (добавить случай в параметризацию `test_settings_put_invalid_value_is_422`, если она по ключу/значению). + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_settings.py tests/test_mirror.py tests/test_api.py -k "closed" -v` +Expected: FAIL (`closed_statuses` нет в настройках, `list_issues` не знает `closed_statuses`). + +- [ ] **Step 3: Implement** + +- `settings.DEFAULTS["closed_statuses"] = ["Готово", "Снято"]` — тип проверит существующая ветка `validate` по + значению по умолчанию (список). +- `mirror.list_issues(conn, filters=None, limit=50, offset=0, closed_statuses=())`: при `filters["hide_closed"]` из + `(True, 1, "1", "true")` и непустом `closed_statuses` — условие + `(i.status IS NULL OR i.status NOT IN (?, …))` с плейсхолдерами по числу статусов. +- `api.h_issues`: `hide_closed` в разобранные фильтры (добавить в `ISSUE_FILTERS`), `closed_statuses` — из + `settings.get(conn, "closed_statuses")`, если это список строк, иначе `()`. +- README и ARCHITECTURE — по строкам из **Files**. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest` — весь набор зелёный. + +- [ ] **Step 5: Commit** + +```bash +git add src/jira_hub tests README.md ARCHITECTURE.md +git commit -m "Хаб: настройка closed_statuses и фильтр GET /issues?hide_closed (#57)" +``` + +--- + +### Task 2: Приложение — помощник закрытых статусов и список «Задачи» + +**Files:** +- Create: `web/src/api/closed.ts`, `web/src/api/closed.test.ts` +- Modify: `web/src/api/types.ts` (`HubSettings`: `closed_statuses?: string[]`; убрать `status_labels` и комментарий о нём) +- Modify: `web/src/api/queries.ts` (`ISSUE_FILTER_NAMES` без `fact_status`; `IssueFilters` допускает `hide_closed`) +- Modify: `web/src/issues/IssuesPage.tsx`, `web/src/issues/options.ts` +- Modify: `web/src/board/__fixtures__/settings.json` (добавить `"closed_statuses": ["Готово", "Снято"]`) +- Test: `web/src/issues/IssuesPage.test.tsx`, `web/e2e/issues.spec.ts` + +**Interfaces:** +- Produces (`web/src/api/closed.ts`): + - `closedStatuses(s: HubSettings | undefined): Set` — строки из `s.closed_statuses`, если это массив; иначе + пустой набор. + - `isClosed(status: string | null | undefined, closed: Set): boolean` — `!!status && closed.has(status)`. + - `useClosedStatuses(): Set` — `closedStatuses(useSettings().data)`, мемоизировано по `settings.data`. +- Consumes: `GET /issues?hide_closed=1` (Task 1). + +- [ ] **Step 1: Write the failing tests** + +`closed.test.ts`: `closedStatuses({closed_statuses: ["Готово", "Снято"]})` — набор из двух; `closedStatuses({})`, +`closedStatuses(undefined)`, `closedStatuses({closed_statuses: "Готово"})` — пустой; `isClosed("Снято", set)` — true, +`isClosed("", set)`, `isClosed(null, set)`, `isClosed("В работе", set)` — false. + +`IssuesPage.test.tsx` (хаб из `hub()` уже отдаёт `settings.json` с `closed_statuses`): +- «закрытые скрыты по умолчанию»: открыт `/issues` → флажок «скрыть закрытые» (`getByRole("checkbox", {name: + "скрыть закрытые"})`) отмечен, последний вызов `/issues` содержит `hide_closed=1`, не содержит `closed`; снятие + флажка → адрес содержит `closed=1`, последний вызов `/issues` без `hide_closed`. +- «адрес closed=1»: открыт `/issues?closed=1` → флажок снят, запрос без `hide_closed`. +- «выбран закрытый статус»: открыт `/issues?status=Готово` → запрос `status=Готово` без `hide_closed`; открыт + `/issues?status=В работе` → с `hide_closed=1`. +- «настройки позже» (Review Focus 1): ответ `/settings` задержан (`deferred`), адрес `/issues?status=Готово`; после + выдачи настроек последний вызов `/issues` — без `hide_closed`. +- «нет статуса по факту»: нет `combobox` «Статус по факту», нет заголовка колонки «Статус по факту»; открыт + `/issues?fact_status=В работе` → в вызове `/issues` нет `fact_status`. +- Существующие тесты, которые сравнивают точный набор параметров `/issues`, поправить на `hide_closed=1`. + +`web/e2e/issues.spec.ts`: в тесте «окно поверх списка» ожидание `toHaveCount(10)` → `9` (AURORA-9006 «Готово» +скрыта); новый тест: `/app/issues` — AURORA-9006 нет в таблице; снят флажок «скрыть закрытые» — `toHaveURL(/closed=1/)`, +AURORA-9006 есть, строк 10. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd web && npx vitest run src/api/closed.test.ts src/issues/IssuesPage.test.tsx` +Expected: FAIL (нет модуля `closed.ts`, нет флажка). + +- [ ] **Step 3: Implement** + +- `closed.ts` — по Interfaces. +- `IssuesPage`: из `SELECTS` убрать `fact_status`, из таблицы — колонку «Статус по факту»; `showClosed = + params.get("closed") === "1"`; флажок `` рядом с «только соседи», снятие ставит `closed=1`, отметка — удаляет `closed`; + в `useIssues` уходят `filters` плюс `hide_closed: "1"`, если `!showClosed && !isClosed(filters.status, closed)`. +- `options.ts`: убрать `fact_status` из `filterOptions`. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `make check`, затем `cd web && npm run e2e` +Expected: всё зелёное. + +- [ ] **Step 5: Commit** + +```bash +git add web +git commit -m "Список задач: «скрыть закрытые» по умолчанию, без статуса по факту (#57)" +``` + +--- + +### Task 3: Карточка задачи и заметки без статуса по факту + +**Files:** +- Modify: `web/src/issues/card.ts` (`isOverdue`; удалить `factClass`, `CLOSED_FACT`) +- Modify: `web/src/issues/IssueCard.tsx` (шапка без бейджа факта; `isOverdue` по статусу Jira) +- Modify: `web/src/issues/Notes.tsx` (`ROWS` без `fact_status`), `web/src/issues/NoteForm.tsx` (поле, `datalist`, + `facts`, `useId` если больше не нужен) +- Modify: `web/src/api/types.ts` (`NOTE_FIELDS` без `fact_status`; тип `Note` — `fact_status?: string | null` остаётся + необязательным полем ответа) +- Modify: `web/src/issues/issues.css` (удалить `.badge.fact*`) +- Modify: `web/src/test/issueFixtures.ts` только если типы требуют +- Test: `web/src/issues/card.test.ts`, `web/src/issues/IssueCard.test.tsx` + +**Interfaces:** +- Consumes: `useClosedStatuses()`, `closedStatuses`, `isClosed` (Task 2). +- Produces: `isOverdue(due: string, status: string | null | undefined, closed: Set, today: string): boolean`. + +- [ ] **Step 1: Write the failing tests** + +`card.test.ts` (заменить тесты `factClass` и R-2): +```ts +const closed = new Set(["Готово", "Снято"]); +it("срок прошёл, статус не закрытый — просрочено", () => { + expect(isOverdue("2026-10-01", "В работе", closed, "2026-10-17")).toBe(true); + expect(isOverdue("2026-10-01", null, closed, "2026-10-17")).toBe(true); +}); +it("закрытая задача или срок не прошёл — не просрочено", () => { + expect(isOverdue("2026-10-01", "Готово", closed, "2026-10-17")).toBe(false); + expect(isOverdue("2026-10-01", "Снято", closed, "2026-10-17")).toBe(false); + expect(isOverdue("2026-10-17", "В работе", closed, "2026-10-17")).toBe(false); + expect(isOverdue("", "В работе", closed, "2026-10-17")).toBe(false); +}); +``` +`IssueCard.test.tsx`: +- шапка: нет элемента `.badge.fact` и текста «факт:»; строка фактов как прежде с «просрочено» (у фикстуры статус не + закрытый); новый тест — та же задача со `status: "Готово"` → «просрочено» нет (сценарий «Просрочка закрытой задачи»). +- заметки в чтении: нет `dt` «Статус по факту»; в форме нет поля с подписью «Статус по факту» (заменить тест + со строки ~390 про `datalist`). +- Review Focus 3: у заметки `fact_status: "В работе"`, в форме изменено только «Осталось», «Сохранить» → тело `PUT` — + ровно `{rest_text: "…", updated_by: "web"}`, ключа `fact_status` нет. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd web && npx vitest run src/issues/card.test.ts src/issues/IssueCard.test.tsx` +Expected: FAIL. + +- [ ] **Step 3: Implement** — по Files и Interfaces; в `IssueCard` набор — `useClosedStatuses()`, вызов + `isOverdue(due, i.status, closed, todayISO())`. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `make check`; `grep -rn "fact_status\|status_labels\|factClass" web/src --exclude-dir=__fixtures__` — остаются +только необязательные поля типов ответа API (`BoardIssue`, `IssueRow`, `Note`) и тестовые фикстуры. + +- [ ] **Step 5: Commit** + +```bash +git add web +git commit -m "Карточка задачи: без статуса по факту, «просрочено» по статусу Jira (#57)" +``` + +--- + +### Task 4: Доска — приглушение закрытых карточек и стрелок + +**Files:** +- Modify: `web/src/board/components/Card.tsx` (`CardProps.closed?: boolean`, класс `closed`, `cardPropsEqual`) +- Modify: `web/src/board/components/Edges.tsx` (`closedKeys: Set`, `className="closed"` у ``) +- Modify: `web/src/board/components/BoardCanvas.tsx` (`BoardCanvasProps.closedStatuses?: Set`) +- Modify: `web/src/board/BoardPage.tsx` (передать `useClosedStatuses()`) +- Modify: `web/src/board/board.css` +- Test: `web/src/board/components/BoardCanvas.test.tsx`, `web/src/board/components/Card.test.ts` + +**Interfaces:** +- Consumes: `useClosedStatuses()`, `isClosed` (Task 2). +- Produces: `BoardCanvas` принимает `closedStatuses` (по умолчанию пустой набор); `Card` — `closed`; `Edges` — + `closedKeys` (ключи закрытых задач среди `vis.nodes`, считаются в `BoardCanvas` через `useMemo`). + +- [ ] **Step 1: Write the failing tests** + +`BoardCanvas.test.tsx` — через `baseProps`/`draw` с `vis`, где AURORA-1620 «В работе», AURORA-1621 «Готово», +AURORA-1622 «Снято», связь AURORA-1620 → AURORA-1621, `closedStatuses: new Set(["Готово", "Снято"])`: +- `[data-key="AURORA-1621"]` и `[data-key="AURORA-1622"]` имеют класс `closed`, `[data-key="AURORA-1620"]` — нет; +- `` связи в `svg.edges` имеет класс `closed`; +- без `closedStatuses` — ни у одной карточки и стрелки класса `closed` нет; +- закрытая карточка-сосед (`dim: true`, «Готово») — классы `out` и `closed` оба. +`Card.test.ts`: `cardPropsEqual` возвращает `false`, если отличается только `closed`. +CSS проверяется правилом в `board.css` (jsdom стили не считает): `.card.closed{opacity:.4}` стоит после `.card.out`. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd web && npx vitest run src/board/components` +Expected: FAIL. + +- [ ] **Step 3: Implement** + +- CSS (после правила `.card.out`): `.card.closed{opacity:.4}`, + `.card.closed:hover,.card.closed:focus-within{opacity:1}`, `.edges path.closed{opacity:.4}`. +- Карточки слоя измерения (`BoardCanvas.tsx:204`) класс `closed` не получают — на размер он не влияет. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `make check`, `cd web && npm run e2e` (golden-тесты доски — без перегенерации эталонов). +Expected: всё зелёное. + +- [ ] **Step 5: Commit** + +```bash +git add web +git commit -m "Доска: закрытые задачи и их связи приглушены (#57)" +``` From 0abed70f44fd02dfe9c7a1bf5b0e2c8a2e54fe36 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 17 Oct 2026 11:31:55 +0400 Subject: [PATCH 3/7] =?UTF-8?q?=D0=A5=D0=B0=D0=B1:=20=D0=BD=D0=B0=D1=81?= =?UTF-8?q?=D1=82=D1=80=D0=BE=D0=B9=D0=BA=D0=B0=20closed=5Fstatuses=20?= =?UTF-8?q?=D0=B8=20=D1=84=D0=B8=D0=BB=D1=8C=D1=82=D1=80=20GET=20/issues?= =?UTF-8?q?=3Fhide=5Fclosed=20(#57)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- ARCHITECTURE.md | 2 +- README.md | 2 +- src/jira_hub/api.py | 7 +++++-- src/jira_hub/mirror.py | 6 +++++- src/jira_hub/settings.py | 1 + tests/test_api.py | 17 ++++++++++++++++- tests/test_mirror.py | 15 +++++++++++++++ tests/test_settings.py | 12 ++++++++++++ 8 files changed, 56 insertions(+), 6 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3abb3f0..988e97a 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -47,7 +47,7 @@ - `mcp_server` не импортирует `queue`/`mirror`/`db` напрямую и ходит только через `client`; из `files` берёт только предел `MAX_SIZE` (файлы в хранилище кладёт хаб по `POST /files`). - `commands`, `mirror`, `queue` не знают про HTTP; HTTP живёт только в `api`, `client` и `relay` (реле — клиент своего же HTTP). -- Веб-приложение (`web/`) ходит в хаб только через HTTP API (`web/src/api/client.ts`); словари `kind_labels`, `link_types`, `status_labels` берёт из `GET /settings` и своих копий не держит. +- Веб-приложение (`web/`) ходит в хаб только через HTTP API (`web/src/api/client.ts`); словари `kind_labels`, `link_types` и набор закрытых статусов `closed_statuses` берёт из `GET /settings` и своих копий не держит. ## Открытые вопросы - Правки из UI в Jira (спринт, срок, связи, исполнитель) — change `viewer-edit`; страницы очереди, агента и настроек — позже. diff --git a/README.md b/README.md index b4c0687..38b28c0 100644 --- a/README.md +++ b/README.md @@ -150,7 +150,7 @@ uv run jira-hub agent publish ../jira-agent/src/agent.py | `GET /agent/known-keys` | агент | ключи зеркала | | `GET /agent/release` | агент | опубликованная версия агента `{version, sha256, content}`; 404 `no_release` | | `GET /agent/files/{sha256}` | агент | файл вложения `{file, size, content}` (`content` — base64); 404 `no_file` | -| `GET /issues` | клиент | фильтры `sprint`, `assignee`, `work_kind`, `fact_status`, `status`, `label`, `epic`, `neighbor`, `q`, `limit`, `offset` | +| `GET /issues` | клиент | фильтры `sprint`, `assignee`, `work_kind`, `fact_status`, `status`, `label`, `epic`, `neighbor`, `q`, `hide_closed` (`1` — без задач в статусах из настройки `closed_statuses`, по умолчанию «Готово», «Снято»), `limit`, `offset` | | `GET /issues/{key}` | клиент | задача целиком | | `GET /board` | клиент | снимок для доски: все задачи с `fact_status`/`work_kind`, все связи, `last_sync` — последний успешный полный прогон | | `GET /`, `GET /links` | — | перенаправление на `/app/board` | diff --git a/src/jira_hub/api.py b/src/jira_hub/api.py index a1f71d4..4826f90 100644 --- a/src/jira_hub/api.py +++ b/src/jira_hub/api.py @@ -214,12 +214,15 @@ def h_known_keys(hub, conn, m, q, body): return 200, {"keys": mirror.known_keys(conn)} -ISSUE_FILTERS = ("sprint", "assignee", "work_kind", "fact_status", "status", "label", "epic", "neighbor", "q") +ISSUE_FILTERS = ("sprint", "assignee", "work_kind", "fact_status", "status", "label", "epic", "neighbor", "q", "hide_closed") def h_issues(hub, conn, m, q, body): filters = {k: q.get(k) for k in ISSUE_FILTERS} - return 200, {"issues": mirror.list_issues(conn, filters, _int_param(q, "limit", 50), _int_param(q, "offset", 0))} + closed = settings.get(conn, "closed_statuses") + closed = [s for s in closed if isinstance(s, str)] if isinstance(closed, list) else [] + return 200, {"issues": mirror.list_issues(conn, filters, _int_param(q, "limit", 50), _int_param(q, "offset", 0), + closed_statuses=closed)} def h_issue(hub, conn, m, q, body): diff --git a/src/jira_hub/mirror.py b/src/jira_hub/mirror.py index 923b670..d616962 100644 --- a/src/jira_hub/mirror.py +++ b/src/jira_hub/mirror.py @@ -80,7 +80,8 @@ def _issue_dict(row) -> dict: return d -def list_issues(conn, filters=None, limit=50, offset=0) -> list[dict]: +def list_issues(conn, filters=None, limit=50, offset=0, closed_statuses=()) -> list[dict]: + """hide_closed — без задач, чей status из closed_statuses (задача без статуса не закрыта).""" filters = filters or {} where, args = [], [] for name, sql in _FILTERS.items(): @@ -93,6 +94,9 @@ def list_issues(conn, filters=None, limit=50, offset=0) -> list[dict]: if filters.get("neighbor") not in (None, ""): where.append("i.neighbor = ?") args.append(1 if filters["neighbor"] in (True, 1, "1", "true") else 0) + if filters.get("hide_closed") in (True, 1, "1", "true") and closed_statuses: + where.append(f"(i.status IS NULL OR i.status NOT IN ({', '.join('?' * len(closed_statuses))}))") + args += list(closed_statuses) if filters.get("q"): where.append("(i.summary LIKE ? OR i.key LIKE ?)") args += [f"%{filters['q']}%", f"%{filters['q']}%"] diff --git a/src/jira_hub/settings.py b/src/jira_hub/settings.py index 23fcf88..5a49561 100644 --- a/src/jira_hub/settings.py +++ b/src/jira_hub/settings.py @@ -31,6 +31,7 @@ DEFAULTS = { "link_types": {}, "board_id": None, "relay_dir": None, + "closed_statuses": ["Готово", "Снято"], # статусы Jira закрытых задач: список и доска приложения (hide-closed) } # Что получает агент (без токенов и служебных ключей хаба) diff --git a/tests/test_api.py b/tests/test_api.py index 9554794..1351418 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -266,7 +266,7 @@ def test_settings_put_protected_key_in_batch_rejects_whole_batch(hub): {"pacing": 5}, {"pacing": {"ensure_fields": "abc"}}, {"pacing": {"ensure_fields": -1}}, {"pacing": {"неизвестная": 1}}, {"port": "x"}, {"port": 0}, {"port": 70000}, {"backup_keep": -1}, {"auto_refresh_minutes": 1.5}, {"min_agent_version": "2"}, {"link_types": []}, {"base_jql": 5}, - {"labels": {}}, {"initialized": 1}, + {"labels": {}}, {"initialized": 1}, {"closed_statuses": "Готово"}, {"pacing": {"refresh": 3}, "backup_keep": -1}, # хорошее рядом с плохим не записывается ]) def test_settings_put_invalid_value_is_422(hub, body): @@ -727,3 +727,18 @@ def test_attachment_flow_over_api(hub): a.post(f"/agent/commands/{cmd['id']}/result", {"status": "failed", "error": "HTTP 500"}) retry = c.post(f"/commands/{cmd['id']}/retry", {"author": "claude"}) assert c.get(f"/batches/{retry['id']}")["commands"][0]["params"] == {"key": "A-1", "file": sha, "filename": "ОСВ 60.xlsx"} + + +def test_issues_hide_closed(hub): + c = hub["client"] + hub["agent"].post("/agent/state", {"issues": [{"key": "A-1", "summary": "Т", "status": "В работе", "labels": []}, + {"key": "A-2", "summary": "Т", "status": "Готово", "labels": []}, + {"key": "A-3", "summary": "Т", "status": "Снято", "labels": []}, + {"key": "A-4", "summary": "Т", "labels": []}], "links": []}) + + def keys(**q): + return sorted(i["key"] for i in c.get("/issues", **q)["issues"]) + assert keys(hide_closed="1") == ["A-1", "A-4"] + assert keys() == ["A-1", "A-2", "A-3", "A-4"] + c.put("/settings", {"closed_statuses": ["Готово"]}) + assert keys(hide_closed="1") == ["A-1", "A-3", "A-4"] diff --git a/tests/test_mirror.py b/tests/test_mirror.py index 5215c94..c638107 100644 --- a/tests/test_mirror.py +++ b/tests/test_mirror.py @@ -240,3 +240,18 @@ def test_check_comments_rejects(bad, field): def test_check_comments_accepts_missing_and_valid(): mirror.check_comments([issue("A-1"), issue("A-2", comments=[comment("1"), comment("2", author=None, body="")])]) + + +def test_list_issues_hide_closed(conn): + mirror.apply_state(conn, [issue("A-1", status="В работе"), issue("A-2", status="Готово"), + issue("A-3", status="Снято"), issue("A-4", status=None)], []) + closed = ["Готово", "Снято"] + + def keys(f, c=closed): + return sorted(i["key"] for i in mirror.list_issues(conn, f, closed_statuses=c)) + assert keys({"hide_closed": "1"}) == ["A-1", "A-4"] # без статуса — не закрыта + assert keys({"hide_closed": "true"}) == ["A-1", "A-4"] + assert keys({}) == ["A-1", "A-2", "A-3", "A-4"] + assert keys({"hide_closed": "0"}) == ["A-1", "A-2", "A-3", "A-4"] + assert keys({"hide_closed": "1"}, []) == ["A-1", "A-2", "A-3", "A-4"] # пустой набор — ничего не скрыто + assert keys({"hide_closed": "1", "status": "Готово"}) == [] # отменяет скрытие приложение, не сервер diff --git a/tests/test_settings.py b/tests/test_settings.py index 9043c62..cbaa702 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -94,3 +94,15 @@ def test_attachment_pacing(conn): assert settings.pacing(conn)["ensure_attachment"] == 30 assert settings.pacing_from_config({"issue_seconds": 90, "comment_seconds": 20})["ensure_attachment"] == 20 settings.validate("pacing", {"ensure_attachment": 10}) + + +def test_closed_statuses_default(conn): + assert settings.get(conn, "closed_statuses") == ["Готово", "Снято"] + assert "closed_statuses" not in settings.AGENT_KEYS + assert "closed_statuses" not in settings.for_agent(conn) + + +def test_closed_statuses_must_be_list(): + with pytest.raises(ValueError): + settings.validate("closed_statuses", "Готово") + settings.validate("closed_statuses", ["Готово"]) From dbda1ad77cc560e700c2ec49a8353f208499291a Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 17 Oct 2026 11:37:14 +0400 Subject: [PATCH 4/7] =?UTF-8?q?=D0=A1=D0=BF=D0=B8=D1=81=D0=BE=D0=BA=20?= =?UTF-8?q?=D0=B7=D0=B0=D0=B4=D0=B0=D1=87:=20=C2=AB=D1=81=D0=BA=D1=80?= =?UTF-8?q?=D1=8B=D1=82=D1=8C=20=D0=B7=D0=B0=D0=BA=D1=80=D1=8B=D1=82=D1=8B?= =?UTF-8?q?=D0=B5=C2=BB=20=D0=BF=D0=BE=20=D1=83=D0=BC=D0=BE=D0=BB=D1=87?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D1=8E,=20=D0=B1=D0=B5=D0=B7=20=D1=81=D1=82?= =?UTF-8?q?=D0=B0=D1=82=D1=83=D1=81=D0=B0=20=D0=BF=D0=BE=20=D1=84=D0=B0?= =?UTF-8?q?=D0=BA=D1=82=D1=83=20(#57)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- web/e2e/issues.spec.ts | 14 ++++- web/src/api/closed.test.ts | 25 +++++++++ web/src/api/closed.ts | 14 +++++ web/src/api/queries.ts | 5 +- web/src/api/types.ts | 4 +- web/src/board/__fixtures__/settings.json | 3 +- web/src/issues/IssuesPage.test.tsx | 68 ++++++++++++++++++++++++ web/src/issues/IssuesPage.tsx | 17 ++++-- web/src/issues/options.ts | 1 - 9 files changed, 139 insertions(+), 12 deletions(-) create mode 100644 web/src/api/closed.test.ts create mode 100644 web/src/api/closed.ts diff --git a/web/e2e/issues.spec.ts b/web/e2e/issues.spec.ts index 912aa74..eca317d 100644 --- a/web/e2e/issues.spec.ts +++ b/web/e2e/issues.spec.ts @@ -23,7 +23,7 @@ test("фильтр → карточка окном → заметка сохра test("окно поверх списка: прокрутка списка на месте при открытии и после закрытия", async ({ page }) => { await page.setViewportSize({ width: 1280, height: 320 }); await login(page, "/app/issues"); - await expect(page.locator("table.grid tbody tr")).toHaveCount(10); + await expect(page.locator("table.grid tbody tr")).toHaveCount(9); // AURORA-9006 «Готово» скрыта const y = await page.evaluate(() => { window.scrollTo(0, document.documentElement.scrollHeight); return window.scrollY; }); expect(y).toBeGreaterThan(50); await page.locator("table.grid tbody tr").last().getByRole("link").click(); @@ -173,3 +173,15 @@ test("узкий экран: окно на всю ширину, заметки await expect(d.getByRole("columnheader", { name: "Источник" })).toBeAttached(); expect(await d.evaluate((el) => el.scrollWidth <= el.clientWidth + 1)).toBe(true); }); + +test("закрытые скрыты по умолчанию, снятый флажок показывает их", async ({ page }) => { + await login(page, "/app/issues"); + await expect(page.locator("table.grid tbody tr")).toHaveCount(9); + await expect(page.getByRole("link", { name: "AURORA-9006", exact: true })).toHaveCount(0); + const hide = page.getByRole("checkbox", { name: "скрыть закрытые" }); + await hide.click(); // не uncheck(): адрес меняется навигацией, флажок следует за ним не сразу + await expect(page).toHaveURL(/closed=1/); + await expect(hide).not.toBeChecked(); + await expect(page.getByRole("link", { name: "AURORA-9006", exact: true })).toBeVisible(); + await expect(page.locator("table.grid tbody tr")).toHaveCount(10); +}); diff --git a/web/src/api/closed.test.ts b/web/src/api/closed.test.ts new file mode 100644 index 0000000..0943f10 --- /dev/null +++ b/web/src/api/closed.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { closedStatuses, isClosed } from "./closed.ts"; + +describe("закрытые статусы", () => { + const set = closedStatuses({ closed_statuses: ["Готово", "Снято"] }); + + it("набор — из closed_statuses настроек", () => { + expect([...set]).toEqual(["Готово", "Снято"]); + }); + + it("нет настройки или она не список — ничего не закрыто", () => { + expect(closedStatuses({}).size).toBe(0); + expect(closedStatuses(undefined).size).toBe(0); + expect(closedStatuses({ closed_statuses: "Готово" }).size).toBe(0); + expect([...closedStatuses({ closed_statuses: ["Готово", 5] })]).toEqual(["Готово"]); + }); + + it("закрыта — только задача со статусом из набора", () => { + expect(isClosed("Снято", set)).toBe(true); + expect(isClosed("В работе", set)).toBe(false); + expect(isClosed("", set)).toBe(false); + expect(isClosed(null, set)).toBe(false); + expect(isClosed(undefined, set)).toBe(false); + }); +}); diff --git a/web/src/api/closed.ts b/web/src/api/closed.ts new file mode 100644 index 0000000..3a8cde1 --- /dev/null +++ b/web/src/api/closed.ts @@ -0,0 +1,14 @@ +import { useMemo } from "react"; +import { useSettings } from "./queries.ts"; +import type { HubSettings } from "./types.ts"; + +// закрытые задачи (hide-closed): статус Jira из настройки closed_statuses; нет настройки или она не список — закрытых нет +export const closedStatuses = (s: HubSettings | undefined): Set => + new Set(Array.isArray(s?.closed_statuses) ? s.closed_statuses.filter((x): x is string => typeof x === "string") : []); + +export const isClosed = (status: string | null | undefined, closed: Set): boolean => !!status && closed.has(status); + +export function useClosedStatuses(): Set { + const settings = useSettings(); + return useMemo(() => closedStatuses(settings.data), [settings.data]); +} diff --git a/web/src/api/queries.ts b/web/src/api/queries.ts index 77a9ff6..c38aa2f 100644 --- a/web/src/api/queries.ts +++ b/web/src/api/queries.ts @@ -2,8 +2,9 @@ import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tansta import { api } from "./client.ts"; import type { BoardSnapshot, HubSettings, IssueDetail, IssueRow, Note, NoteFields } from "./types.ts"; -export const ISSUE_FILTER_NAMES = ["sprint", "assignee", "work_kind", "fact_status", "status", "label", "epic", "neighbor", "q"] as const; -export type IssueFilters = Partial>; +// фильтры списка в адресе; hide_closed в адрес не пишется — его выводит список из флажка «скрыть закрытые» (closed=1 — снят) +export const ISSUE_FILTER_NAMES = ["sprint", "assignee", "work_kind", "status", "label", "epic", "neighbor", "q"] as const; +export type IssueFilters = Partial>; export const ISSUE_PAGE = 50; export const qk = { diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 544932a..c727781 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -11,11 +11,11 @@ export interface SyncRun { started_at: string; finished_at: string; issues_count export interface BoardSnapshot { generated_at: string; last_sync: SyncRun | null; issues: BoardIssue[]; links: BoardLink[] } // словари настроек: kind_labels — вид работы → метка Jira, link_types — логический тип → имя типа связи Jira, -// status_labels — статус по факту → метка Jira +// closed_statuses — статусы Jira закрытых задач export interface HubSettings { kind_labels?: Record; link_types?: Record; - status_labels?: Record; + closed_statuses?: unknown; project_key?: string; [key: string]: unknown; } diff --git a/web/src/board/__fixtures__/settings.json b/web/src/board/__fixtures__/settings.json index 15d12bc..a66c22d 100644 --- a/web/src/board/__fixtures__/settings.json +++ b/web/src/board/__fixtures__/settings.json @@ -1,5 +1,6 @@ { "kind_labels": {"аналитика": "kind-analytics", "решение": "kind-decision", "актуализация": "kind-docs", "разработка": "kind-dev", "UI": "kind-ui", "тестирование": "kind-test", "организационная": "kind-org"}, "link_types": {"блокирует начало": "ASUPR_Блокер_Начала", "блокирует завершение": "ASUPR_Блокер_Завершения", "связанные": "ASUPR_Связанные"}, - "status_labels": {"Готово — закрыть": "fact-done", "В работе": "fact-in-progress", "Ожидание разработки": "fact-open"} + "status_labels": {"Готово — закрыть": "fact-done", "В работе": "fact-in-progress", "Ожидание разработки": "fact-open"}, + "closed_statuses": ["Готово", "Снято"] } diff --git a/web/src/issues/IssuesPage.test.tsx b/web/src/issues/IssuesPage.test.tsx index 76f404d..32c03dd 100644 --- a/web/src/issues/IssuesPage.test.tsx +++ b/web/src/issues/IssuesPage.test.tsx @@ -150,4 +150,72 @@ describe("список задач", () => { expect(within(sel).queryByRole("option", { name: /нет на доске/ })).toBeNull(); expect(within(sel).getByRole("option", { name: "AWPCA_Спринт 2" })).toBeInTheDocument(); }); + + const hideBox = () => screen.getByRole("checkbox", { name: "скрыть закрытые" }); + + it("закрытые скрыты по умолчанию; снятый флажок — closed=1 и запрос без hide_closed", async () => { + const fetch = hub(() => [row(1)]); + const { router } = renderWithProviders(, { route: "/issues", path: "/issues" }); + await screen.findByRole("link", { name: "AURORA-9001" }); + expect(hideBox()).toBeChecked(); + let q = issuesCalls(fetch).at(-1)!.searchParams; + expect(q.get("hide_closed")).toBe("1"); + expect(q.has("closed")).toBe(false); + await userEvent.click(hideBox()); + await waitFor(() => expect(router.state.location.search).toBe("?closed=1")); + await waitFor(() => expect(issuesCalls(fetch).at(-1)!.searchParams.has("hide_closed")).toBe(false)); + q = issuesCalls(fetch).at(-1)!.searchParams; + expect(q.has("closed")).toBe(false); + await userEvent.click(hideBox()); + await waitFor(() => expect(router.state.location.search).toBe("")); + expect(hideBox()).toBeChecked(); // выборка с hide_closed=1 уже в кэше — повторного запроса может не быть + }); + + it("адрес closed=1 — флажок снят, запрос без hide_closed", async () => { + const fetch = hub(() => [row(1)]); + renderWithProviders(, { route: "/issues?closed=1", path: "/issues" }); + await screen.findByRole("link", { name: "AURORA-9001" }); + expect(hideBox()).not.toBeChecked(); + expect(issuesCalls(fetch).every((u) => !u.searchParams.has("hide_closed"))).toBe(true); + }); + + it("выбран закрытый статус Jira — hide_closed не отправляется", async () => { + const fetch = hub(() => [row(1)]); + renderWithProviders(, { route: "/issues?status=Готово", path: "/issues" }); + await screen.findByRole("link", { name: "AURORA-9001" }); + const q = issuesCalls(fetch).at(-1)!.searchParams; + expect(q.get("status")).toBe("Готово"); + expect(q.has("hide_closed")).toBe(false); + expect(hideBox()).toBeChecked(); + }); + + it("выбран незакрытый статус Jira — hide_closed отправляется", async () => { + const fetch = hub(() => [row(1)]); + renderWithProviders(, { route: "/issues?status=В работе", path: "/issues" }); + await screen.findByRole("link", { name: "AURORA-9001" }); + expect(issuesCalls(fetch).at(-1)!.searchParams.get("hide_closed")).toBe("1"); + }); + + it("настройки пришли позже — запрос по закрытому статусу без hide_closed", async () => { + const st = deferred(); + const fetch = mockFetch((_m, url) => { + if (url.pathname === "/settings") return st.promise; + if (url.pathname === "/board") return json(200, SNAP); + return json(200, { issues: [row(1)] }); + }); + renderWithProviders(, { route: "/issues?status=Снято", path: "/issues" }); + st.resolve(json(200, settings)); + await screen.findByRole("link", { name: "AURORA-9001" }); + const calls = () => fetch.mock.calls.map(([u]) => new URL(String(u), "http://hub.test")).filter((u) => u.pathname === "/issues"); + await waitFor(() => expect(calls().at(-1)!.searchParams.has("hide_closed")).toBe(false)); + }); + + it("статуса по факту нет: ни фильтра, ни колонки; fact_status из адреса не уходит в запрос", async () => { + const fetch = hub(() => [row(1)]); + renderWithProviders(, { route: "/issues?fact_status=В работе", path: "/issues" }); + await screen.findByRole("link", { name: "AURORA-9001" }); + expect(screen.queryByLabelText("Статус по факту")).toBeNull(); + expect(screen.queryByRole("columnheader", { name: "Статус по факту" })).toBeNull(); + expect(issuesCalls(fetch).every((u) => !u.searchParams.has("fact_status"))).toBe(true); + }); }); diff --git a/web/src/issues/IssuesPage.tsx b/web/src/issues/IssuesPage.tsx index 44da18b..7157572 100644 --- a/web/src/issues/IssuesPage.tsx +++ b/web/src/issues/IssuesPage.tsx @@ -1,13 +1,14 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { useSearchParams } from "react-router"; +import { isClosed, useClosedStatuses } from "../api/closed.ts"; import { ISSUE_FILTER_NAMES, useBoard, useIssues, useSettings, type IssueFilters } from "../api/queries.ts"; import { IssueLink } from "./IssueLink.tsx"; import { dueOf, epicNames, filterOptions, withCurrent } from "./options.ts"; import "./issues.css"; const SELECTS = [ - ["sprint", "Спринт"], ["assignee", "Исполнитель"], ["work_kind", "Вид работы"], ["fact_status", "Статус по факту"], - ["status", "Статус Jira"], ["label", "Метка"], ["epic", "Эпик"], + ["sprint", "Спринт"], ["assignee", "Исполнитель"], ["work_kind", "Вид работы"], ["status", "Статус Jira"], + ["label", "Метка"], ["epic", "Эпик"], ] as const; const Q_DELAY = 300; // поиск — после паузы ввода (R-7) @@ -37,7 +38,12 @@ export function IssuesPage() { const board = useBoard(), settings = useSettings(); const opts = useMemo(() => filterOptions(board.data, settings.data), [board.data, settings.data]); const names = useMemo(() => epicNames(board.data), [board.data]); - const list = useIssues(filters); + // закрытые скрыты, пока флажок не снят (closed=1); выбранный закрытый статус Jira скрытие отменяет + const showClosed = params.get("closed") === "1"; + const closed = useClosedStatuses(); + const query = useMemo(() => (showClosed || isClosed(filters.status, closed) ? filters : { ...filters, hide_closed: "1" }), + [filters, showClosed, closed]); + const list = useIssues(query); const rows = list.data?.pages.flat() ?? []; return ( @@ -53,6 +59,7 @@ export function IssuesPage() { ))} + setQ(e.target.value)} /> {list.isError && ( @@ -60,13 +67,13 @@ export function IssuesPage() {

)} - + {rows.map((i) => ( - + ))} diff --git a/web/src/issues/options.ts b/web/src/issues/options.ts index aa151d7..1be05d4 100644 --- a/web/src/issues/options.ts +++ b/web/src/issues/options.ts @@ -24,7 +24,6 @@ export function filterOptions(snap: BoardSnapshot | undefined, s: HubSettings | sprint: opt(uniq(issues.map((i) => i.sprint))), assignee: opt(uniq(issues.map((i) => i.assignee))), work_kind: opt(uniq([...Object.keys(s?.kind_labels ?? {}), ...issues.map((i) => i.work_kind)])), - fact_status: opt(uniq([...Object.keys(s?.status_labels ?? {}), ...issues.map((i) => i.fact_status)])), status: opt(uniq(issues.map((i) => i.status))), label: opt(uniq(issues.flatMap((i) => i.labels))), epic: uniq(issues.map((i) => i.epic_key)).map((k) => ({ value: k, label: names[k] ?? k })), From c5e33a9cc4102c052eff6257a8044802ef382acb Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 17 Oct 2026 11:41:47 +0400 Subject: [PATCH 5/7] =?UTF-8?q?=D0=9A=D0=B0=D1=80=D1=82=D0=BE=D1=87=D0=BA?= =?UTF-8?q?=D0=B0=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=87=D0=B8:=20=D0=B1=D0=B5?= =?UTF-8?q?=D0=B7=20=D1=81=D1=82=D0=B0=D1=82=D1=83=D1=81=D0=B0=20=D0=BF?= =?UTF-8?q?=D0=BE=20=D1=84=D0=B0=D0=BA=D1=82=D1=83,=20=C2=AB=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D1=81=D1=80=D0=BE=D1=87=D0=B5=D0=BD=D0=BE=C2=BB=20=D0=BF?= =?UTF-8?q?=D0=BE=20=D1=81=D1=82=D0=B0=D1=82=D1=83=D1=81=D1=83=20Jira=20(#?= =?UTF-8?q?57)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- web/src/api/types.ts | 5 +++-- web/src/issues/IssueCard.test.tsx | 29 ++++++++++++++++++++--------- web/src/issues/IssueCard.tsx | 10 ++++------ web/src/issues/NoteForm.tsx | 8 +------- web/src/issues/Notes.tsx | 2 +- web/src/issues/card.test.ts | 25 +++++++++++-------------- web/src/issues/card.ts | 13 ++++--------- web/src/issues/issues.css | 3 --- 8 files changed, 44 insertions(+), 51 deletions(-) diff --git a/web/src/api/types.ts b/web/src/api/types.ts index c727781..746574c 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -28,10 +28,11 @@ export interface IssueRow { neighbor: boolean; fact_status?: string | null; work_kind?: string | null; } -export const NOTE_FIELDS = ["fact_status", "work_kind", "done_text", "rest_text", "owner_note"] as const; +// поля заметки, которые приложение показывает и правит; fact_status хаб ещё отдаёт, приложение его не читает (hide-closed) +export const NOTE_FIELDS = ["work_kind", "done_text", "rest_text", "owner_note"] as const; export type NoteField = (typeof NOTE_FIELDS)[number]; export type NoteFields = Partial>; -export type Note = Record & { key: string; updated_at: string; updated_by: string }; +export type Note = Record & { key: string; fact_status?: string | null; updated_at: string; updated_by: string }; export interface IssueCommand { id: number; batch_id: number; type: string; status: string; finished_at: string | null; error: string | null; diff --git a/web/src/issues/IssueCard.test.tsx b/web/src/issues/IssueCard.test.tsx index e6c25c9..f80c656 100644 --- a/web/src/issues/IssueCard.test.tsx +++ b/web/src/issues/IssueCard.test.tsx @@ -40,14 +40,15 @@ function overflow(scroll: number, client: number) { describe("карточка задачи", () => { beforeEach(() => setToken("tok")); - it("шапка: ключ, статусы, тема, исполнитель · срок · спринт · эпик по имени; ссылка в Jira", async () => { + it("шапка: ключ, статус Jira, тема, исполнитель · срок · спринт · эпик по имени; ссылка в Jira", async () => { mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { planned_end: "2020-01-10" }))); open("/issues/AURORA-9001"); expect(await screen.findByRole("heading", { level: 2, name: "Тема AURORA-9001" })).toBeInTheDocument(); const head = screen.getByTestId("issue-head"); expect(within(head).getByText("AURORA-9001")).toBeInTheDocument(); expect(within(head).getByText("В работе", { selector: ".badge.status" })).toBeInTheDocument(); - expect(within(head).getByText("факт: В работе", { selector: ".badge.fact" })).toHaveClass("fact-in-progress"); // как в макете раздела 1 + expect(head.querySelector(".badge.fact")).toBeNull(); // статуса по факту в карточке нет (hide-closed) + expect(within(head).queryByText(/факт:/)).toBeNull(); expect(within(head).getByTestId("issue-facts")).toHaveTextContent("Иванов Иван Иванович · срок 10.01 просрочено · AWPCA_Спринт 2 · эпик: Тестовый эпик"); expect(within(head).getByText("просрочено")).toHaveClass("overdue"); expect(screen.getByRole("link", { name: "Открыть в Jira ↗" })).toHaveAttribute("href", "https://sdlc.go.rshbank.ru/jira/browse/AURORA-9001"); @@ -383,14 +384,24 @@ describe("карточка задачи", () => { expect(screen.queryByRole("columnheader", { name: "Команда" })).toBeNull(); }); - it("T14.3: id подсказок «статуса по факту» уникален и связан с полем", async () => { + it("закрытая задача со сроком в прошлом — без пометки «просрочено»", async () => { + mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { planned_end: "2020-01-10", status: "Готово" }))); + open("/issues/AURORA-9001"); + const facts = await screen.findByTestId("issue-facts"); + expect(facts).toHaveTextContent("срок 10.01"); + expect(within(facts).queryByText("просрочено")).toBeNull(); + }); + + it("статуса по факту нет ни в чтении заметок, ни в форме", async () => { mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001"))); open("/issues/AURORA-9001"); - await userEvent.click(await screen.findByRole("button", { name: "Править" })); - const input = await screen.findByLabelText("Статус по факту"); - const id = input.getAttribute("list")!; - expect(id).not.toBe("fact-statuses"); - expect(document.getElementById(id)?.tagName).toBe("DATALIST"); + const view = await screen.findByRole("region", { name: "Служебные заметки" }); + expect(view).not.toHaveTextContent("Статус по факту"); + expect(view).toHaveTextContent("Вид работы"); + await userEvent.click(screen.getByRole("button", { name: "Править" })); + await screen.findByLabelText("Осталось"); + expect(screen.queryByLabelText("Статус по факту")).toBeNull(); + expect(document.querySelector("datalist")).toBeNull(); }); it("заметки в режиме чтения: пустые — «—», время и автор; «Отмена» без правок — сразу", async () => { @@ -425,7 +436,7 @@ describe("карточка задачи", () => { mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { note: null }))); open("/issues/AURORA-9001"); const view = await screen.findByRole("region", { name: "Служебные заметки" }); - expect(within(view).getAllByText("—")).toHaveLength(5); + expect(within(view).getAllByText("—")).toHaveLength(4); // вид работы, сделано, осталось, заметка владельца expect(within(view).getByRole("button", { name: "Править" })).toBeInTheDocument(); }); diff --git a/web/src/issues/IssueCard.tsx b/web/src/issues/IssueCard.tsx index b719366..14757b7 100644 --- a/web/src/issues/IssueCard.tsx +++ b/web/src/issues/IssueCard.tsx @@ -1,10 +1,11 @@ import { type RefObject, useLayoutEffect, useRef, useState } from "react"; import { ApiError } from "../api/client.ts"; +import { useClosedStatuses } from "../api/closed.ts"; import { useIssue, useSettings } from "../api/queries.ts"; import type { IssueComment, IssueDetail } from "../api/types.ts"; import { JIRA_URL } from "../board/constants.ts"; import { fmtDate, fmtStamp } from "../board/model/util.ts"; -import { factClass, groupLinks, isOverdue, todayISO } from "./card.ts"; +import { groupLinks, isOverdue, todayISO } from "./card.ts"; import { useCommentsHidden } from "./commentsPref.ts"; import { IssueLink } from "./IssueLink.tsx"; import { Notes } from "./Notes.tsx"; @@ -17,6 +18,7 @@ const DESC_LINES = 8; export function IssueCard({ issueKey: key, onClose }: { issueKey: string; onClose: () => void }) { const issue = useIssue(key); const settings = useSettings(); + const closed = useClosedStatuses(); const close = ; if (issue.isPending) return

загрузка {key}…

{close}
; if (issue.error instanceof ApiError && issue.error.status === 404) { @@ -32,13 +34,10 @@ export function IssueCard({ issueKey: key, onClose }: { issueKey: string; onClos ); } const i = issue.data!; - const labels = settings.data?.status_labels as Record | undefined; - const fact = i.note?.fact_status ?? null; - const cls = factClass(fact, labels); const due = dueOf(i); const facts = [ i.assignee && {i.assignee}, - due && срок {fmtDate(due)}{isOverdue(due, fact, labels, todayISO()) && <> просрочено}, + due && срок {fmtDate(due)}{isOverdue(due, i.status, closed, todayISO()) && <> просрочено}, i.sprint && {i.sprint}, i.epic_key && эпик: {i.epic_summary ?? i.epic_key}, ].filter(Boolean); @@ -48,7 +47,6 @@ export function IssueCard({ issueKey: key, onClose }: { issueKey: string; onClos
{i.key} {i.status && {i.status}} - {fact && факт: {fact}} Открыть в Jira ↗ {close} diff --git a/web/src/issues/NoteForm.tsx b/web/src/issues/NoteForm.tsx index d5fa280..e41e9df 100644 --- a/web/src/issues/NoteForm.tsx +++ b/web/src/issues/NoteForm.tsx @@ -1,4 +1,4 @@ -import { useEffect, useId, useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useBlocker } from "react-router"; import { useCloseAbort } from "./closeAbort.ts"; import { ApiError } from "../api/client.ts"; @@ -41,8 +41,6 @@ export function NoteForm({ issue, onDone }: { issue: IssueDetail; onDone: () => const kinds = Object.keys(settings.data?.kind_labels ?? {}); const kindOptions = values.work_kind && !kinds.includes(values.work_kind) ? [values.work_kind, ...kinds] : kinds; - const facts = Object.keys(settings.data?.status_labels ?? {}); - const listId = useId(); const set = (f: NoteField) => (e: { target: { value: string } }) => setValues((v) => ({ ...v, [f]: e.target.value })); const submit = () => save.mutate(changed, { @@ -60,10 +58,6 @@ export function NoteForm({ issue, onDone }: { issue: IssueDetail; onDone: () => {kindOptions.map((k) => )} - {TEXTS.map(([f, label]) => (
КлючТемаТипСтатус JiraИсполнительСпринтСрокЭпикВид работыСтатус по фактуСосед
КлючТемаТипСтатус JiraИсполнительСпринтСрокЭпикВид работыСосед
{i.summary}{i.issue_type}{i.status} {i.assignee}{i.sprint}{dueOf(i)}{i.epic_key ? names[i.epic_key] ?? i.epic_key : ""}{i.work_kind}{i.fact_status}{i.neighbor ? "да" : ""}{i.work_kind}{i.neighbor ? "да" : ""}