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() {