Merge pull request 'Закрытые задачи: скрыть в списке, приглушить на доске; без статуса по факту в приложении (#57)' (#58) from feat/57-hide-closed into main

This commit is contained in:
aep 2026-10-17 10:57:43 +03:00
commit dde40973bd
38 changed files with 1036 additions and 79 deletions

View File

@ -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`; страницы очереди, агента и настроек — позже.

View File

@ -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` |
@ -186,7 +186,10 @@ launchctl kickstart -k gui/$(id -u)/ru.aepif.jira-hub
читает те же ключи `localStorage`, что прежняя страница. Разработка: `bash web/scripts/test-hub.sh` (тестовый хаб
:8791 с синтетическими данными) и `cd web && npm run dev` (http://127.0.0.1:5173/app/).
Карточка задачи: в шапке — статус Jira, статус по факту, исполнитель, срок (просроченный — красным), спринт и эпик;
Список задач по умолчанию скрывает закрытые (статус Jira из настройки `closed_statuses`, по умолчанию «Готово», «Снято»):
флажок «скрыть закрытые», снятый — `closed=1` в адресе; на доске закрытые задачи и их связи приглушены.
Карточка задачи: в шапке — статус Jira, исполнитель, срок (просроченный — красным; у закрытой задачи пометки нет), спринт и эпик;
связи сгруппированы «Зависит от» / «Блокирует» / «Связана с» / «Прочие» с темой и статусом связанной задачи;
комментарии из Jira (агент v10+) новые сверху, «Скрыть» запоминается в браузере; заметки — режим чтения, «Править»
открывает форму. Комментарии появляются после первого обновления из Jira агентом v10.

View File

@ -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 <noreply@anthropic.com>`.
- Зелёным считается только весь набор: `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<string>` — строки из `s.closed_statuses`, если это массив; иначе
пустой набор.
- `isClosed(status: string | null | undefined, closed: Set<string>): boolean` — `!!status && closed.has(status)`.
- `useClosedStatuses(): Set<string>` — `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"`; флажок `<label className="check"><input type="checkbox" checked={!showClosed} …
/>скрыть закрытые</label>` рядом с «только соседи», снятие ставит `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<string>, 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<string>`, `className="closed"` у `<path>`)
- Modify: `web/src/board/components/BoardCanvas.tsx` (`BoardCanvasProps.closedStatuses?: Set<string>`)
- 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"]` — нет;
- `<path>` связи в `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)"
```

View File

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-17

View File

@ -0,0 +1,103 @@
## 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` (`<path>` на каждую связь). Настройки — `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
1. 2026-10-17 — **набор закрытых статусов в настройке хаба `closed_statuses`**, а не константой в коде. Обоснование:
правило «Справочники из настроек хаба», фильтр нужен и серверу, и приложению. Цена ошибки: лишний ключ настроек;
убрать — правка `DEFAULTS` и двух мест чтения.
2. 2026-10-17 — **фильтр `hide_closed` на сервере**, а не в браузере. Обоснование: порции по 50. Цена ошибки: один
параметр API; поведение MCP не меняется.
3. 2026-10-17 — **адрес: `closed=1` — показать закрытые; по умолчанию параметра нет.** Обоснование: умолчание владельца
«скрыты», адрес без лишних параметров. Цена ошибки: переименование параметра.
4. 2026-10-17 — **выбранный в «Статус Jira» закрытый статус отменяет скрытие.** Обоснование: иначе выборка всегда пуста.
Цена ошибки: поведение одного сочетания фильтров.
5. 2026-10-17 — **приглушение: `opacity .4`, полная яркость под указателем и с фокусом клавиатуры внутри; стрелки — если закрыт хотя бы один
конец.** Обоснование: владелец подтвердил «связи тоже бледнеют»; степень прозрачности — на глаз, отличается от
соседа (`.7`, пунктир). Цена ошибки: правка CSS.
6. 2026-10-17 — **без `closed_statuses` — ничего не закрыто, без сообщения об ошибке.** Обоснование: это не словарь
подписей, без него страница работает как раньше. Цена ошибки: нет.
7. 2026-10-17 — **«просрочено» — по статусу Jira из `closed_statuses`.** Обоснование: статус по факту из приложения
убран. Цена ошибки: пометка «просрочено» у задачи, закрытой по факту, но не в Jira, — теперь видна (это и есть
статус Jira).
Решения исполнения (маркеры по ledger SDD):
8. 2026-10-17 — **R-1: e2e-шаг флажка — `click()` + `not.toBeChecked()` вместо `uncheck()`.** Обоснование: флажок
управляется адресом, React Router меняет его навигацией асинхронно, `uncheck()` проверяет состояние сразу после
щелчка; так же устроен «только соседи». Цена ошибки: нет, поведение то же.
9. 2026-10-17 — **R-2: «PUT заметки не шлёт `fact_status`» отдельным тестом не дублируется.** Обоснование: тест
сохранения в `IssueCard.test.tsx` идёт на фикстуре с `fact_status` и сравнивает тело целиком. Цена ошибки: нет.
10. 2026-10-17 — **R-3: в заметке без данных — 4 прочерка вместо 5.** Обоснование: полей заметки в приложении
четыре по спеке. Цена ошибки: нет.
11. 2026-10-17 — **R-4: тест приглушения на доске считает закрытые по статусам выборки.** Обоснование: в фикстуре
`hub-basic` уже есть AURORA-9006 «Готово». Цена ошибки: нет.
12. 2026-10-17 — **R-5: находки финального ревью M-1 (README описывал статус по факту в карточке) и M-2 (формат даты в
сценарии спеки) исправлены, а не отложены.** Обоснование: документация и спека — источник истины — противоречили
поведению; тестом не проверяются. Цена ошибки: нет.

View File

@ -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`.

View File

@ -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` агенту этого ключа не передаёт

View File

@ -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` — все четыре

View File

@ -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» без пометки «просрочено»; при статусе «В работе» — с пометкой
### 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` «тестирование»); окно закрывается без потери раскладки и прокрутки доски

View File

@ -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):

View File

@ -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']}%"]

View File

@ -31,6 +31,7 @@ DEFAULTS = {
"link_types": {},
"board_id": None,
"relay_dir": None,
"closed_statuses": ["Готово", "Снято"], # статусы Jira закрытых задач: список и доска приложения (hide-closed)
}
# Что получает агент (без токенов и служебных ключей хаба)

View File

@ -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"]

View File

@ -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": "Готово"}) == [] # отменяет скрытие приложение, не сервер

View File

@ -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", ["Готово"])

View File

@ -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);
});

View File

@ -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);
});
});

14
web/src/api/closed.ts Normal file
View File

@ -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<string> =>
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<string>): boolean => !!status && closed.has(status);
export function useClosedStatuses(): Set<string> {
const settings = useSettings();
return useMemo(() => closedStatuses(settings.data), [settings.data]);
}

View File

@ -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<Record<(typeof ISSUE_FILTER_NAMES)[number], string>>;
// фильтры списка в адресе; hide_closed в адрес не пишется — его выводит список из флажка «скрыть закрытые» (closed=1 — снят)
export const ISSUE_FILTER_NAMES = ["sprint", "assignee", "work_kind", "status", "label", "epic", "neighbor", "q"] as const;
export type IssueFilters = Partial<Record<(typeof ISSUE_FILTER_NAMES)[number] | "hide_closed", string>>;
export const ISSUE_PAGE = 50;
export const qk = {

View File

@ -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<string, string>;
link_types?: Record<string, string>;
status_labels?: Record<string, string>;
closed_statuses?: unknown;
project_key?: string;
[key: string]: unknown;
}
@ -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<Record<NoteField, string | null>>;
export type Note = Record<NoteField, string | null> & { key: string; updated_at: string; updated_by: string };
export type Note = Record<NoteField, string | null> & { 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;

View File

@ -1,4 +1,5 @@
import { useCallback, useMemo, useRef, useState } from "react";
import { useClosedStatuses } from "../api/closed.ts";
import { useSettings } from "../api/queries.ts";
import { computeVisible, emptyReason } from "./analysis/visible.ts";
import { BoardCanvas } from "./components/BoardCanvas.tsx";
@ -17,6 +18,7 @@ import "./board.css";
export function BoardPage() {
const settings = useSettings();
const closedStatuses = useClosedStatuses();
const d = useMemo(() => (settings.data ? dictsFromSettings(settings.data) : null), [settings.data]);
const src = useBoardSource(d && !d.missing.length ? d.dicts : null);
const prefs = useBoardPrefs(src.model);
@ -76,7 +78,7 @@ export function BoardPage() {
</div>
{exportText !== null && <ExportBox text={exportText} onClose={() => setExportText(null)} />}
<BoardCanvas vis={vis} byDate={prefs.filters.byDate} snap={prefs.snap} today={today} store={prefs.store}
sprintSel={prefs.filters.sprints} emptyText={vis.nodes.length ? "" : emptyReason(src.model, vis, prefs.filters)}
sprintSel={prefs.filters.sprints} emptyText={vis.nodes.length ? "" : emptyReason(src.model, vis, prefs.filters)} closedStatuses={closedStatuses}
onToggleLane={prefs.toggleLane} onMoveLane={(name, to) => prefs.moveLane(vis.lanes.map((l) => l.name), name, to)}
onColWidth={prefs.setColWidth} saveRef={saveRef}>
{prefs.problems && <ProblemsDrawer problems={vis.problems} onClose={() => prefs.toggle("problems")} />}

View File

@ -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": ["Готово", "Снято"]
}

View File

@ -90,6 +90,9 @@ svg.guides line{stroke:var(--guide);stroke-width:1;stroke-dasharray:4 3}
.card{position:absolute;width:240px;height:120px;box-sizing:border-box;background:var(--card);border:1px solid var(--line);border-radius:6px;padding:12px 14px;display:flex;flex-direction:column;gap:4px;overflow:hidden;cursor:default;touch-action:none}
.card:hover{border-color:var(--accent)}
.card.out{border-style:dashed;opacity:.7}
.card.closed{opacity:.4}
.card.closed:hover,.card.closed:focus-within{opacity:1}
.edges path.closed{opacity:.4}
.card.crit{box-shadow:0 0 0 2px var(--guide)}
.card.focus{box-shadow:0 0 0 2px var(--accent)}
.card.drag{cursor:grabbing;border-color:var(--accent);box-shadow:0 6px 18px rgba(0,0,0,.18);z-index:2}

View File

@ -32,6 +32,41 @@ function draw(over: Partial<Parameters<typeof BoardCanvas>[0]> = {}) {
const board = () => document.querySelector(".board") as HTMLElement;
describe("BoardCanvas", () => {
// закрытые (hide-closed): src первой видимой связи — «Готово», её dst — «Снято» (плюс закрытые из фикстуры)
function closedVis() {
const vis = baseProps().vis;
const l = vis.links[0];
const st: Record<string, string> = { [l.src]: "Готово", [l.dst]: "Снято" };
return { vis: { ...vis, nodes: vis.nodes.map((n) => (st[n.key] ? { ...n, status: st[n.key] } : n)) }, l };
}
const CLOSED = new Set(["Готово", "Снято"]);
it("закрытые карточки и стрелки, у которых закрыт хоть один конец, — класс closed", () => {
const { vis, l } = closedVis();
draw({ vis, closedStatuses: CLOSED });
const keys = vis.nodes.filter((n) => CLOSED.has(n.status)).map((n) => n.key); // в фикстуре ещё AURORA-9006 «Готово»
expect(keys).toEqual(expect.arrayContaining([l.src, l.dst]));
const closedCards = [...board().querySelectorAll(".card.closed[data-key]")].map((e) => e.getAttribute("data-key")).sort();
expect(closedCards).toEqual([...keys].sort());
const touching = vis.links.filter((x) => keys.includes(x.src) || keys.includes(x.dst)).length;
expect(document.querySelectorAll("svg.edges > path.closed")).toHaveLength(touching);
expect(document.querySelectorAll("svg.edges > path:not(.closed)").length).toBe(vis.links.length - touching);
expect(document.querySelectorAll(".measure .card.closed")).toHaveLength(0); // слой измерения — без приглушения
});
it("без closedStatuses — ничего не приглушено", () => {
draw({ vis: closedVis().vis });
expect(document.querySelectorAll(".card.closed, svg.edges > path.closed")).toHaveLength(0);
});
it("закрытый сосед — и out, и closed", () => {
const { vis, l } = closedVis();
draw({ vis: { ...vis, nodes: vis.nodes.map((n) => (n.key === l.src ? { ...n, dim: true } : n)) }, closedStatuses: CLOSED });
const card = board().querySelector(`.card[data-key="${l.src}"]`)!;
expect(card).toHaveClass("out");
expect(card).toHaveClass("closed");
});
it("дорожки по эпикам и карточки задач с заглушкой", () => {
draw();
const lanes = [...board().querySelectorAll(".lane-name")].map((e) => e.textContent);

View File

@ -1,4 +1,5 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type PointerEvent, type ReactNode } from "react";
import { isClosed } from "../../api/closed.ts";
import { CW, GRID, MINH, MINW, TOP } from "../constants.ts";
import { alignEdges } from "../layout/align.ts";
import { clampToCol, computeLayout, measuredHeight, place, type Layout, type Placed, type RelBox } from "../layout/layout.ts";
@ -15,6 +16,7 @@ export interface LayoutStore { laneOrder: string[]; folded: Set<string>; colWidt
export interface BoardCanvasProps {
vis: Visible; byDate: boolean; snap: boolean; today: string; store: LayoutStore; sprintSel: Set<string>;
emptyText: string;
closedStatuses?: Set<string>; // статусы Jira закрытых задач: их карточки и стрелки приглушены (hide-closed), на модель не влияют
saveRef?: { current: (() => void) | null };
onToggleLane(name: string): void;
onMoveLane(name: string, to: number): void;
@ -22,7 +24,9 @@ export interface BoardCanvasProps {
children?: ReactNode;
}
export function BoardCanvas({ vis, byDate, snap, today, store, sprintSel, emptyText, onToggleLane, onMoveLane, onColWidth, saveRef, children }: BoardCanvasProps) {
const NONE = new Set<string>();
export function BoardCanvas({ vis, byDate, snap, today, store, sprintSel, emptyText, closedStatuses = NONE, onToggleLane, onMoveLane, onColWidth, saveRef, children }: BoardCanvasProps) {
const boardRef = useRef<HTMLDivElement>(null);
const measureRef = useRef<HTMLDivElement>(null);
const wrapRef = useRef<HTMLDivElement>(null);
@ -33,6 +37,7 @@ export function BoardCanvas({ vis, byDate, snap, today, store, sprintSel, emptyT
const [guides, setGuides] = useState<{ x: number | null; y: number | null } | null>(null);
const [scroll, setScroll] = useState({ left: 0, wide: false, wrapW: 0, scrollW: 0 });
const [dragTarget, setDragTarget] = useState<{ from: number; to: number } | null>(null);
const closedKeys = useMemo(() => new Set(vis.nodes.filter((n) => isClosed(n.status, closedStatuses)).map((n) => n.key)), [vis.nodes, closedStatuses]);
// раскладка как render() links.html: при новой выборке, режиме и ширинах колонок — заново, с сохранённой из хранилища
useLayoutEffect(() => {
@ -185,9 +190,9 @@ export function BoardCanvas({ vis, byDate, snap, today, store, sprintSel, emptyT
))}
{Object.entries(placed.pos).filter(([k]) => byKey[k]).map(([k, box]) => (
<Card key={k} node={byKey[k]} box={box} rel={rel[k]} multiSprint={multiSprint} sprintSel={sprintSel}
onPointerDown={onCardDown} onAutoSize={onAutoSize} />
closed={closedKeys.has(k)} onPointerDown={onCardDown} onAutoSize={onAutoSize} />
))}
<Edges links={vis.links} placed={placed} />
<Edges links={vis.links} placed={placed} closedKeys={closedKeys} />
<Guides x={guides?.x ?? null} y={guides?.y ?? null} width={placed.width} height={placed.height} />
</>
)}

View File

@ -12,5 +12,6 @@ describe("cardPropsEqual (T11.1)", () => {
expect(cardPropsEqual(base, { ...base, box: { x: 9, y: 2, w: 240, h: 120 } })).toBe(false);
expect(cardPropsEqual(base, { ...base, node: { key: "A-2" } as CardProps["node"] })).toBe(false);
expect(cardPropsEqual(base, { ...base, sprintSel: new Set(["S"]) })).toBe(false);
expect(cardPropsEqual(base, { ...base, closed: true })).toBe(false);
});
});

View File

@ -12,6 +12,7 @@ export interface CardProps {
rel?: RelBox;
multiSprint: boolean;
sprintSel: Set<string>;
closed?: boolean; // задача закрыта (статус из closed_statuses) — карточка приглушена
onPointerDown?: (ev: PointerEvent<HTMLDivElement>, key: string) => void;
onAutoSize?: (key: string) => void;
}
@ -19,12 +20,12 @@ export interface CardProps {
// T11.1: при перетаскивании одной карточки place() создаёт новые box у всех — сравниваем box по значению
export function cardPropsEqual(a: CardProps, b: CardProps): boolean {
const sameBox = a.box === b.box || (!!a.box && !!b.box && a.box.x === b.box.x && a.box.y === b.box.y && a.box.w === b.box.w && a.box.h === b.box.h);
return sameBox && a.node === b.node && a.rel === b.rel && a.multiSprint === b.multiSprint && a.sprintSel === b.sprintSel &&
return sameBox && a.node === b.node && a.rel === b.rel && a.multiSprint === b.multiSprint && a.sprintSel === b.sprintSel && !!a.closed === !!b.closed &&
a.onPointerDown === b.onPointerDown && a.onAutoSize === b.onAutoSize;
}
export const Card = memo(function Card({ node: n, box, rel, multiSprint, sprintSel, onPointerDown, onAutoSize }: CardProps) {
const cls = "card" + (n.dim || n.stub ? " out" : "") + (n.crit ? " crit" : "") + (n.focus ? " focus" : "");
export const Card = memo(function Card({ node: n, box, rel, multiSprint, sprintSel, closed, onPointerDown, onAutoSize }: CardProps) {
const cls = "card" + (n.dim || n.stub ? " out" : "") + (closed ? " closed" : "") + (n.crit ? " crit" : "") + (n.focus ? " focus" : "");
const style = box ? { left: box.x, top: box.y, width: box.w, height: box.h } : { width: CW };
const showTag = !n.stub && (multiSprint || n.dim); // метка спринта — если спринтов на доске больше одного или это сосед
return (

View File

@ -3,7 +3,7 @@ import { edgePath } from "../layout/edges.ts";
import { RELATED } from "../constants.ts";
import type { VisLink } from "../types.ts";
export function Edges({ links, placed }: { links: VisLink[]; placed: Placed }) {
export function Edges({ links, placed, closedKeys }: { links: VisLink[]; placed: Placed; closedKeys?: Set<string> }) {
return (
<svg className="edges">
<defs>
@ -18,7 +18,8 @@ export function Edges({ links, placed }: { links: VisLink[]; placed: Placed }) {
const a = placed.pos[l.src], b = placed.pos[l.dst];
if (!a || !b) return null;
const related = l.type === RELATED;
return <path key={`${l.src}>${l.dst}${l.type}`} d={edgePath(a, b)} fill="none"
const closed = !!closedKeys && (closedKeys.has(l.src) || closedKeys.has(l.dst)); // закрыт хоть один конец — приглушена
return <path key={`${l.src}>${l.dst}${l.type}`} d={edgePath(a, b)} fill="none" className={closed ? "closed" : undefined}
stroke={related ? "var(--edge)" : l.hand ? "var(--guide)" : "var(--edge-strong)"}
strokeWidth={l.crit ? "4" : related ? "1.4" : "1.8"}
strokeDasharray={related ? "5 4" : undefined}

View File

@ -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();
});

View File

@ -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 = <button className="close" type="button" aria-label="Закрыть" onClick={onClose}>✕</button>;
if (issue.isPending) return <div className="issue" data-testid="issue-card"><div className="issue-head"><p className="hint">загрузка {key}…</p>{close}</div></div>;
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<string, string> | undefined;
const fact = i.note?.fact_status ?? null;
const cls = factClass(fact, labels);
const due = dueOf(i);
const facts = [
i.assignee && <span key="who">{i.assignee}</span>,
due && <span key="due">срок {fmtDate(due)}{isOverdue(due, fact, labels, todayISO()) && <> <span className="overdue">просрочено</span></>}</span>,
due && <span key="due">срок {fmtDate(due)}{isOverdue(due, i.status, closed, todayISO()) && <> <span className="overdue">просрочено</span></>}</span>,
i.sprint && <span key="sprint">{i.sprint}</span>,
i.epic_key && <span key="epic">эпик: {i.epic_summary ?? i.epic_key}</span>,
].filter(Boolean);
@ -48,7 +47,6 @@ export function IssueCard({ issueKey: key, onClose }: { issueKey: string; onClos
<div className="row1">
<span className="key">{i.key}</span>
{i.status && <span className="badge status">{i.status}</span>}
{fact && <span className={"badge fact" + (cls ? " " + cls : "")}>факт: {fact}</span>}
<span className="spacer" />
<a href={`${JIRA_URL}/browse/${i.key}`} target="_blank" rel="noopener">Открыть в Jira ↗</a>
{close}

View File

@ -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(<IssuesPage />, { 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(<IssuesPage />, { 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(<IssuesPage />, { 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(<IssuesPage />, { 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<Response>();
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(<IssuesPage />, { 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(<IssuesPage />, { 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);
});
});

View File

@ -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<IssueFilters>(() => (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() {
</label>
))}
<label className="check"><input type="checkbox" checked={filters.neighbor === "1"} onChange={(e) => set("neighbor", e.target.checked ? "1" : "")} />только соседи</label>
<label className="check"><input type="checkbox" checked={!showClosed} onChange={(e) => set("closed", e.target.checked ? "" : "1")} />скрыть закрытые</label>
<input type="search" aria-label="Поиск по ключу или теме" placeholder="ключ или тема" value={q} onChange={(e) => setQ(e.target.value)} />
</div>
{list.isError && (
@ -60,13 +67,13 @@ export function IssuesPage() {
<button className="btn sm" type="button" onClick={() => void list.refetch()}>Повторить</button></p>
)}
<table className="grid">
<thead><tr><th>Ключ</th><th>Тема</th><th>Тип</th><th>Статус Jira</th><th>Исполнитель</th><th>Спринт</th><th>Срок</th><th>Эпик</th><th>Вид работы</th><th>Статус по факту</th><th>Сосед</th></tr></thead>
<thead><tr><th>Ключ</th><th>Тема</th><th>Тип</th><th>Статус Jira</th><th>Исполнитель</th><th>Спринт</th><th>Срок</th><th>Эпик</th><th>Вид работы</th><th>Сосед</th></tr></thead>
<tbody>
{rows.map((i) => (
<tr key={i.key}>
<td><IssueLink issueKey={i.key} /></td><td>{i.summary}</td><td>{i.issue_type}</td><td>{i.status}</td>
<td>{i.assignee}</td><td>{i.sprint}</td><td>{dueOf(i)}</td><td>{i.epic_key ? names[i.epic_key] ?? i.epic_key : ""}</td>
<td>{i.work_kind}</td><td>{i.fact_status}</td><td>{i.neighbor ? "да" : ""}</td>
<td>{i.work_kind}</td><td>{i.neighbor ? "да" : ""}</td>
</tr>
))}
</tbody>

View File

@ -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) => <option key={k} value={k}>{k}</option>)}
</select>
</label>
<label>Статус по факту
<input list={listId} value={values.fact_status} onChange={set("fact_status")} />
<datalist id={listId}>{facts.map((f) => <option key={f} value={f} />)}</datalist>
</label>
{TEXTS.map(([f, label]) => (
<label key={f}>{label}<textarea rows={3} value={values[f]} onChange={set(f)} /></label>
))}

View File

@ -3,7 +3,7 @@ import type { IssueDetail, NoteField } from "../api/types.ts";
import { fmtStamp } from "../board/model/util.ts";
import { NoteForm } from "./NoteForm.tsx";
const ROWS: [NoteField, string][] = [["work_kind", "Вид работы"], ["fact_status", "Статус по факту"], ["done_text", "Сделано"],
const ROWS: [NoteField, string][] = [["work_kind", "Вид работы"], ["done_text", "Сделано"],
["rest_text", "Осталось"], ["owner_note", "Заметка владельца"]];
// служебные заметки (design 20): по умолчанию чтение, «Править» — форма; после сохранения или отмены — снова чтение

View File

@ -1,9 +1,9 @@
import { describe, expect, it } from "vitest";
import settings from "../board/__fixtures__/settings.json";
import { factClass, groupLinks, isOverdue, todayISO } from "./card.ts";
import { groupLinks, isOverdue, todayISO } from "./card.ts";
const types = settings.link_types as Record<string, string>;
const labels = { ...(settings.status_labels as Record<string, string>), "Снято": "fact-cancelled" };
const closed = new Set(["Готово", "Снято"]);
const out = (k: string, t: string, s: string | null = `Тема ${k}`, st: string | null = "В работе") => ({ target_key: k, link_type: t, summary: s, status: st });
const inn = (k: string, t: string, s: string | null = `Тема ${k}`, st: string | null = "В работе") => ({ source_key: k, link_type: t, summary: s, status: st });
@ -35,19 +35,16 @@ describe("groupLinks", () => {
});
});
describe("просрочка и класс статуса по факту", () => {
it("класс из status_labels", () => {
expect(factClass("Готово — закрыть", labels)).toBe("fact-done");
expect(factClass("свой статус", labels)).toBeNull();
expect(factClass(null, labels)).toBeNull();
describe("просрочка", () => {
it("срок прошёл, статус Jira не закрытый — просрочено", () => {
expect(isOverdue("2026-10-01", "В работе", closed, "2026-10-17")).toBe(true);
expect(isOverdue("2026-10-01", null, closed, "2026-10-17")).toBe(true);
});
it("срок раньше сегодня и не закрыта по факту — просрочено (R-2)", () => {
expect(isOverdue("2026-10-14", "В работе", labels, "2026-10-15")).toBe(true);
expect(isOverdue("2026-10-14", null, labels, "2026-10-15")).toBe(true);
expect(isOverdue("2026-10-15", "В работе", labels, "2026-10-15")).toBe(false);
expect(isOverdue("2026-10-14", "Готово — закрыть", labels, "2026-10-15")).toBe(false);
expect(isOverdue("2026-10-14", "Снято", labels, "2026-10-15")).toBe(false);
expect(isOverdue("", "В работе", labels, "2026-10-15")).toBe(false);
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);
});
it("todayISO — местная дата YYYY-MM-DD", () => {
expect(todayISO(new Date(2026, 0, 5, 23, 30))).toBe("2026-01-05");

View File

@ -1,3 +1,4 @@
import { isClosed } from "../api/closed.ts";
import type { IssueDetail } from "../api/types.ts";
export type LinkGroupId = "depends" | "blocks" | "related" | "other";
@ -23,15 +24,9 @@ export function groupLinks(d: Pick<IssueDetail, "links_out" | "links_in">, linkT
return GROUPS.filter(([id]) => groups[id].length).map(([id, title]) => [id, title, groups[id].sort(byKey)]);
}
const CLOSED_FACT = new Set(["fact-done", "fact-cancelled"]);
export const factClass = (fact: string | null | undefined, statusLabels: Record<string, string> | undefined): string | null =>
(fact && statusLabels?.[fact]) || null;
// R-2: просрочено — срок раньше сегодня, а статус по факту не «закрыт» (классы fact-done, fact-cancelled)
export function isOverdue(due: string, fact: string | null | undefined, statusLabels: Record<string, string> | undefined, today: string): boolean {
if (!due || due >= today) return false;
const cls = factClass(fact, statusLabels);
return !(cls && CLOSED_FACT.has(cls));
// просрочено — срок раньше сегодня, а статус Jira не из closed_statuses (hide-closed; было — по статусу по факту, R-2 #37)
export function isOverdue(due: string, status: string | null | undefined, closed: Set<string>, today: string): boolean {
return !!due && due < today && !isClosed(status, closed);
}
export const todayISO = (d = new Date()): string =>

View File

@ -16,9 +16,6 @@
.issue .overdue{color:var(--p-crit);font-weight:600}
.issue .close{font-size:18px;line-height:1;background:none;border:0;color:var(--muted);cursor:pointer;padding:4px 6px}
.badge{display:inline-block;font-size:12px;line-height:18px;padding:0 8px;border-radius:9px;background:var(--soft);color:var(--fg);white-space:nowrap}
.badge.fact{background:transparent;border:1px solid var(--accent);color:var(--accent)}
.badge.fact.fact-done{border-color:var(--k-test);color:var(--k-test)}
.badge.fact.fact-cancelled{border-color:var(--muted);color:var(--muted);text-decoration:line-through}
.issue .cols{display:grid;grid-template-columns:minmax(0,1fr) 320px;gap:24px;align-items:start}
.issue .side{display:flex;flex-direction:column;gap:16px;min-width:0}
.issue .side .note{align-self:stretch} /* .note{align-self:start} — с прежней страницы; в колонке окна — на всю ширину */

View File

@ -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 })),