sdlc-hub/docs/superpowers/plans/2026-10-15-issue-card-modal.md
Andrey Epifancev 9484d10d0b docs: план issue-card-modal (#37); уточнения design 5, 10–13, 25
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 12:52:09 +04:00

1950 lines
114 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# issue-card-modal — карточка задачи окном, комментарии из Jira — план хаба
> **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 со скрытием);
хаб хранит комментарии, присланные агентом v10, и отдаёт их вместе с темами и статусами связанных задач.
**Architecture:** хаб — таблица `issue_comment` (схема v3), запись в `mirror.apply_state`, проверка формы в
`h_agent_state`, расширенный `mirror.get_issue`, комментарии в MCP `issue`. Веб — маршрут верхнего уровня
`{path: "*", element: <Layout/>}`; `Layout` рисует страницы через `useRoutes(PAGE_ROUTES, pageLocation)` с явным адресом
страницы под окном (`location.state.background`) и окно `<dialog>` (`IssueModal` → `IssueCard`) при адресе
`/issues/:key`. Логика без React — в `issues/modal.ts` (состояние истории) и `issues/card.ts` (группы связей,
просрочка).
**Tech Stack:** Python 3.13 (uv), stdlib `sqlite3`/`http.server`, pytest; React 19 + TypeScript, React Router 8.4,
TanStack Query 5, Vitest 5 + Testing Library (jsdom 29), Playwright.
**Spec:** `openspec/changes/issue-card-modal/` — `proposal.md`, `design.md` (решения 5–26, R-1…R-5), `specs/`
(`data-model`, `hub-api`, `mcp`, `web-app`; `agent` — для jira-agent, план
`../jira-agent/.wt/agent-v10/docs/superpowers/plans/2026-10-15-agent-v10-comments.md`, исполняется отдельно и раньше).
**Worktree:** `/Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/issue-modal`, ветка `feat/37-issue-card-modal`.
Все команды — из корня worktree (фронт — из `web/`). Живой хаб :8790 и `~/.local/share/jira-hub` не трогать; ручные
проверки — только тестовый хаб `bash web/scripts/test-hub.sh` (:8791).
## Global Constraints
- Схема базы — v3 (`db.SCHEMA_VERSION = 3`); таблица `issue_comment (key, comment_id, author, created, updated, body)`,
`PRIMARY KEY (key, comment_id)`.
- Комментарии задачи заменяются присланными, только если у задачи в состоянии есть поле `comments` (список); без поля —
не трогаются.
- Неверная форма `comments` в `POST /agent/state` — 422 `bad_state`, текст называет ключ задачи и поле; пачка не
применяется целиком.
- `GET /issues/{key}`: `comments` новые сверху (`created` убыв., затем `comment_id` как число убыв.), `epic_summary`,
у связей `summary` и `status` связанной задачи или `null`. Прежние поля не меняются.
- MCP `issue`: «комментарии (N):», 5 последних, текст до 300 символов с «…», «ещё M»; без комментариев — «комментариев нет».
- Тексты интерфейса — дословно как в спеке: «Зависит от», «Блокирует», «Связана с», «Прочие», «начало», «завершение»,
«нет в зеркале», «связей нет», «развернуть»/«свернуть», «Комментарии (N)», «Скрыть»/«Показать», «комментариев нет»,
«Править», «Отмена», «Отменить правки заметки?», «Прочие поля», «Команды моста (N)», «команд по задаче не было»,
«просрочено», «Открыть в Jira ↗», «Закрыть», «Задачи <key> нет в зеркале хаба.», «Есть несохранённые правки заметки.
Уйти без сохранения?».
- Описание сворачивается свыше 8 строк; выбор «скрыто» у комментариев — `localStorage["jira-hub-comments-hidden"] = "1"`.
- Окно: до 1000 px шириной, до 90vh высотой; уже 700 px — на всю ширину; уже 900 px — одна колонка (заметки, связи,
описание, комментарии, прочие поля, команды).
- Новых npm-зависимостей нет.
- Тесты: `uv run pytest` и `cd web && npm test` — зелёными считается только весь набор; перед коммитом каждой задачи
фронта — `cd web && npm run lint && npx tsc -b`.
- Коммиты на русском, трейлер `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.
## Review Focus
1. Фоновая страница не пересоздаётся при открытии и закрытии окна (фильтры, догруженные порции, прокрутка) — Task 4, тест «фон не пересоздаётся».
2. Перезагрузка страницы с открытым окном: под окном та же страница, закрытие возвращает на неё — Task 6, e2e «прямой адрес» и первый тест `issues.spec.ts` (reload).
3. Esc при несохранённой заметке не закрывает окно молча — Task 4, тест «Esc с несохранённой заметкой спрашивает».
4. Комментарий с пустым текстом или без автора не ломает карточку — Task 5, тест «комментарий без автора и текста».
5. Связь на задачу вне зеркала и тип связи вне `link_types` — Task 3 (`groupLinks`) и Task 5 (отрисовка «нет в зеркале»).
---
### Task 1: Комментарии в зеркале и расширенная карточка в хабе
**Модель исполнителя:** haiku (готовый код).
**Files:**
- Modify: `src/jira_hub/db.py` (`SCHEMA_VERSION`, `SCHEMA`, `MIGRATIONS`)
- Modify: `src/jira_hub/mirror.py` (`apply_state`, `get_issue`, новая `check_comments`)
- Test: `tests/test_db.py`, `tests/test_mirror.py`, `tests/test_e2e_hub.py:78`
**Interfaces:**
- Produces: `mirror.check_comments(issues: list[dict]) -> None` (бросает `ValueError("<ключ>: comments[<i>].<поле> — …")`);
`mirror.get_issue` → дополнительно `epic_summary: str | None`, `comments: list[{comment_id, author, created, updated,
body}]`, у `links_out`/`links_in` — `summary`, `status`.
- [ ] **Step 1: Падающие тесты схемы**
В `tests/test_db.py` в `test_connect_creates_schema` заменить множество таблиц и версию:
```python
assert {"setting", "issue", "issue_link", "issue_comment", "issue_note", "batch", "command", "sync_run",
"agent_status"} <= names
assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "3"
```
Во всём файле `tests/test_db.py` строки `... .fetchone()["value"] == "2"` (миграция v1 и два соединения) заменить на
`== "3"`. После `test_migrates_v1_with_backup` добавить:
```python
def test_migrates_v2_with_backup(tmp_path):
"""База v2 (без issue_comment) с задачей и заметкой → v3: копия schema-v2, таблица комментариев пустая, данные на месте."""
path = tmp_path / "hub.sqlite"
conn = db.connect(path)
conn.execute("INSERT INTO issue(key, summary, fetched_at) VALUES ('A-1', 'Т', '2026-10-15T00:00:00+00:00')")
conn.execute("INSERT INTO issue_note(key, fact_status) VALUES ('A-1', 'В работе')")
conn.execute("DROP TABLE issue_comment")
conn.execute("UPDATE setting SET value = '2' WHERE key = 'schema_version'")
conn.close()
conn = db.connect(path)
assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "3"
assert conn.execute("SELECT COUNT(*) AS n FROM issue_comment").fetchone()["n"] == 0
assert conn.execute("SELECT summary FROM issue WHERE key = 'A-1'").fetchone()["summary"] == "Т"
assert conn.execute("SELECT fact_status FROM issue_note WHERE key = 'A-1'").fetchone()["fact_status"] == "В работе"
copies = list((tmp_path / "backups").glob("*-schema-v2.sqlite"))
assert len(copies) == 1
conn.close()
```
- [ ] **Step 2: Падающие тесты зеркала**
В `tests/test_mirror.py` заменить ожидания связей (строки 30, 31, 38):
```python
assert got["links_out"] == [{"target_key": "A-2", "link_type": "ASUPR_Связанные", "summary": "Тема A-2",
"status": "Ожидание разработки"}]
assert mirror.get_issue(conn, "A-2")["links_in"] == [{"source_key": "A-1", "link_type": "ASUPR_Связанные",
"summary": "Новая тема", "status": "Ожидание разработки"}]
```
и в `test_links_from_other_source_survive` (строка 38):
```python
assert mirror.get_issue(conn, "A-1")["links_in"] == [{"source_key": "A-2", "link_type": "ASUPR_Связанные",
"summary": "Тема A-2", "status": "Ожидание разработки"}]
```
В конец файла добавить:
```python
def comment(cid, created="2026-10-14T12:00:00.000+0300", **over):
base = {"id": cid, "author": "Петров Пётр", "created": created, "updated": created, "body": f"текст {cid}"}
base.update(over)
return base
def test_comments_replaced_and_kept_without_field(conn):
mirror.apply_state(conn, [issue("A-1", comments=[comment("101"), comment("102")])], [])
mirror.apply_state(conn, [issue("A-1", comments=[comment("102", body="новый текст")])], [])
got = mirror.get_issue(conn, "A-1")["comments"]
assert [(c["comment_id"], c["body"]) for c in got] == [("102", "новый текст")]
mirror.apply_state(conn, [issue("A-1", summary="Без поля comments")], []) # агент до v10
assert [c["comment_id"] for c in mirror.get_issue(conn, "A-1")["comments"]] == ["102"]
mirror.apply_state(conn, [issue("A-1", comments=[])], [])
assert mirror.get_issue(conn, "A-1")["comments"] == []
def test_comments_newest_first_and_shape(conn):
mirror.apply_state(conn, [issue("A-1", comments=[
comment("9", created="2026-10-14T10:00:00.000+0300"),
comment("10", created="2026-10-15T10:00:00.000+0300", author=None, body=None),
comment("11", created="2026-10-15T10:00:00.000+0300")])], [])
got = mirror.get_issue(conn, "A-1")["comments"]
assert [c["comment_id"] for c in got] == ["11", "10", "9"] # то же время — по номеру, как число
assert got[1] == {"comment_id": "10", "author": None, "created": "2026-10-15T10:00:00.000+0300",
"updated": "2026-10-15T10:00:00.000+0300", "body": ""}
def test_issue_epic_summary_and_absent_link(conn):
mirror.apply_state(conn, [issue("A-1", epic_key="E-1"), issue("E-1", summary="Эпик свода", epic_key=None)],
[{"source_key": "A-1", "target_key": "Z-9", "link_type": "ASUPR_Связанные"}])
got = mirror.get_issue(conn, "A-1")
assert got["epic_summary"] == "Эпик свода"
assert got["links_out"] == [{"target_key": "Z-9", "link_type": "ASUPR_Связанные", "summary": None, "status": None}]
assert mirror.get_issue(conn, "E-1")["epic_summary"] is None
@pytest.mark.parametrize("bad, field", [
("x", "comments"), (["x"], "comments[0]"), ([{"id": "", "body": "т"}], "comments[0].id"),
([{"id": 5, "body": "т"}], "comments[0].id"), ([{"id": "5", "body": 42}], "comments[0].body"),
([{"id": "5", "body": "т", "author": 7}], "comments[0].author"),
([{"id": "5", "body": "т", "created": 1}], "comments[0].created"),
([{"id": "5", "body": "т", "updated": []}], "comments[0].updated"),
])
def test_check_comments_rejects(bad, field):
with pytest.raises(ValueError) as e:
mirror.check_comments([issue("A-1"), issue("A-2", comments=bad)])
assert str(e.value).startswith(f"A-2: {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="")])])
```
В `tests/test_e2e_hub.py` строку 78 заменить на:
```python
links_in = client.get("/issues/A-1")["links_in"]
assert [(l["source_key"], l["link_type"]) for l in links_in] == [("A-90", "ASUPR_Связанные")]
```
- [ ] **Step 3: Тесты падают**
Run: `uv run pytest tests/test_db.py tests/test_mirror.py -q`
Expected: FAIL — `issue_comment` нет в таблицах, версия `"2"`, `KeyError: 'comments'`, `AttributeError: module
'jira_hub.mirror' has no attribute 'check_comments'`.
- [ ] **Step 4: Схема v3**
В `src/jira_hub/db.py`: `SCHEMA_VERSION = 3`. В `SCHEMA` после индекса `ix_issue_link_target` добавить:
```sql
CREATE TABLE IF NOT EXISTS issue_comment (
key TEXT NOT NULL,
comment_id TEXT NOT NULL,
author TEXT,
created TEXT,
updated TEXT,
body TEXT NOT NULL DEFAULT '',
PRIMARY KEY (key, comment_id)
);
```
В `MIGRATIONS` добавить шаг (таблицу создаёт `SCHEMA` через `CREATE TABLE IF NOT EXISTS` до миграции; шаг нужен, чтобы
цикл миграции дошёл до версии 3):
```python
2: [], # v2 → v3: issue_comment создаётся SCHEMA (CREATE TABLE IF NOT EXISTS), данных переносить не нужно
```
- [ ] **Step 5: Запись, проверка и чтение комментариев**
В `src/jira_hub/mirror.py` заменить строку документации модуля на:
```python
"""Зеркало задач Jira (issue, issue_link, issue_comment) и служебные заметки (issue_note).
Зеркало пишет только загрузка состояния от агента; заметки обновление зеркала не трогает."""
```
После `NOTE_FIELDS` добавить:
```python
def check_comments(issues) -> None:
"""Форма поля comments у задач состояния (агент v10+); поле необязательно. Нарушение — ValueError с ключом и полем."""
for it in issues:
if "comments" not in it:
continue
key, lst = it.get("key"), it["comments"]
if not isinstance(lst, list):
raise ValueError(f"{key}: comments — список комментариев")
for n, c in enumerate(lst):
where = f"{key}: comments[{n}]"
if not isinstance(c, dict):
raise ValueError(f"{where} — объект комментария")
if not isinstance(c.get("id"), str) or not c["id"]:
raise ValueError(f"{where}.id — непустая строка")
if not isinstance(c.get("body"), str) and c.get("body") is not None:
raise ValueError(f"{where}.body — строка")
for f in ("author", "created", "updated"):
if c.get(f) is not None and not isinstance(c[f], str):
raise ValueError(f"{where}.{f} — строка или null")
```
В `apply_state`, внутри цикла `for it in issues:` после `keys.append(it["key"])` добавить:
```python
if isinstance(it.get("comments"), list): # без поля (агент до v10) комментарии не трогаем
conn.execute("DELETE FROM issue_comment WHERE key = ?", (it["key"],))
conn.executemany(
"INSERT OR REPLACE INTO issue_comment(key, comment_id, author, created, updated, body) "
"VALUES (?, ?, ?, ?, ?, ?)",
[(it["key"], c["id"], c.get("author"), c.get("created"), c.get("updated"), c.get("body") or "")
for c in it["comments"]])
```
В `get_issue` заменить блок от `d["links_out"] = ...` до `d["note"] = get_note(conn, key)` на:
```python
epic = conn.execute("SELECT summary FROM issue WHERE key = ?", (d["epic_key"],)).fetchone() if d.get("epic_key") else None
d["epic_summary"] = epic["summary"] if epic else None
d["links_out"] = [dict(r) for r in conn.execute(
"SELECT l.target_key, l.link_type, i.summary, i.status FROM issue_link l LEFT JOIN issue i ON i.key = l.target_key "
"WHERE l.source_key = ? ORDER BY l.link_type, l.target_key", (key,))]
d["links_in"] = [dict(r) for r in conn.execute(
"SELECT l.source_key, l.link_type, i.summary, i.status FROM issue_link l LEFT JOIN issue i ON i.key = l.source_key "
"WHERE l.target_key = ? ORDER BY l.link_type, l.source_key", (key,))]
d["comments"] = [dict(r) for r in conn.execute(
"SELECT comment_id, author, created, updated, body FROM issue_comment WHERE key = ? "
"ORDER BY created DESC, CAST(comment_id AS INTEGER) DESC, comment_id DESC", (key,))]
d["note"] = get_note(conn, key)
```
- [ ] **Step 6: Тесты проходят, весь набор зелёный**
Run: `uv run pytest tests/test_db.py tests/test_mirror.py tests/test_e2e_hub.py -q`
Expected: PASS.
Run: `uv run pytest -q`
Expected: все тесты зелёные, кроме `tests/test_api.py` строки 75 (ожидание `links_out` без `summary`) — его правит
Task 2. Если упало что-то ещё — чинить в этой задаче.
- [ ] **Step 7: Commit**
```bash
git add src/jira_hub/db.py src/jira_hub/mirror.py tests/test_db.py tests/test_mirror.py tests/test_e2e_hub.py
git commit -m "feat(hub): комментарии задач в зеркале, схема v3; темы и статусы связанных в карточке (#37)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
### Task 2: Проверка состояния от агента и комментарии в MCP
**Модель исполнителя:** haiku (готовый код).
**Files:**
- Modify: `src/jira_hub/api.py` (`h_agent_state`)
- Modify: `src/jira_hub/mcp_server.py` (`fmt_issue`)
- Test: `tests/test_api.py`, `tests/test_mcp.py`
**Interfaces:**
- Consumes: Task 1 — `mirror.check_comments`, `comments`/`epic_summary`/`summary`/`status` в `get_issue`.
- Produces: 422 `bad_state` у `POST /agent/state`; строки комментариев в `fmt_issue`.
- [ ] **Step 1: Падающие тесты API**
В `tests/test_api.py` строку 75 заменить на:
```python
assert issue["links_out"] == [{"target_key": "B-1", "link_type": "ASUPR_Связанные", "summary": "Сосед", "status": None}]
```
В конец файла добавить:
```python
def test_agent_state_comments_validated_whole_batch(hub):
a, c = hub["agent"], hub["client"]
a.post("/agent/state", {"issues": [{"key": "X-1", "summary": "Икс", "labels": []}], "links": []})
with pytest.raises(HubError) as e:
a.post("/agent/state", {"issues": [{"key": "X-1", "summary": "Икс новый", "labels": []},
{"key": "Y-1", "summary": "Игрек", "labels": [],
"comments": [{"id": "7", "body": 42}]}], "links": []})
assert e.value.status == 422 and e.value.error == "bad_state"
assert "Y-1" in e.value.detail and "body" in e.value.detail
assert c.get("/issues/X-1")["summary"] == "Икс" # пачка не применена целиком
with pytest.raises(HubError) as e:
c.get("/issues/Y-1")
assert e.value.status == 404
def test_issue_detail_with_comments(hub):
hub["agent"].post("/agent/state", {"issues": [
{"key": "A-1", "summary": "Схема", "labels": [], "epic_key": "E-1", "comments": [
{"id": "1", "author": "Петров Пётр", "created": "2026-10-14T12:00:00.000+0300", "updated": None, "body": "старый"},
{"id": "2", "author": None, "created": "2026-10-15T12:00:00.000+0300", "updated": None, "body": "новый"}]},
{"key": "E-1", "summary": "Эпик", "labels": []}], "links": []})
got = hub["client"].get("/issues/A-1")
assert [x["body"] for x in got["comments"]] == ["новый", "старый"]
assert got["epic_summary"] == "Эпик"
```
- [ ] **Step 2: Падающий тест MCP**
В `tests/test_mcp.py` в конец файла добавить:
```python
def _detail(**over):
d = {"key": "A-1", "summary": "Т", "labels": [], "links_out": [], "links_in": [], "note": None, "commands": [],
"comments": [], "description": None}
d.update(over)
return d
def test_fmt_issue_comments():
assert "комментариев нет" in mcp_server.fmt_issue(_detail())
comments = [{"comment_id": str(n), "author": f"Автор {n}", "created": f"2026-10-1{n}T12:00:00.000+0300",
"updated": None, "body": ("д" * 500 if n == 7 else f"текст {n}\nстрока")} for n in range(7, 0, -1)]
text = mcp_server.fmt_issue(_detail(comments=comments))
assert "комментарии (7):" in text
assert "д" * 299 + "…" in text and "д" * 300 not in text # самый новый — первым, обрезан до 300
assert "Автор 3" in text and "Автор 2" not in text # 5 последних: 7…3
assert "ещё 2" in text
assert text.index("заметки:") < text.index("комментарии (7):") < text.index("команды:")
no_author = mcp_server.fmt_issue(_detail(comments=[{"comment_id": "1", "author": None, "created": None,
"updated": None, "body": ""}]))
assert "комментарии (1):" in no_author and "ещё" not in no_author
```
- [ ] **Step 3: Тесты падают**
Run: `uv run pytest tests/test_api.py tests/test_mcp.py -q`
Expected: FAIL — 422 не возвращается (пачка применяется), в `fmt_issue` нет «комментариев нет».
- [ ] **Step 4: Реализация**
В `src/jira_hub/api.py` заменить `h_agent_state` целиком:
```python
def h_agent_state(hub, conn, m, q, body):
issues = body.get("issues") or []
try:
mirror.check_comments(issues) # до записи: неверная пачка не применяется целиком
except ValueError as e:
raise ApiError(422, "bad_state", str(e))
with db.transaction(conn):
out = mirror.apply_state(conn, issues, body.get("links") or [], body.get("neighbor_keys") or [],
authoritative=queue.is_full_refresh(conn, body.get("command_id")))
if body.get("command_id"):
queue.record_state(conn, body["command_id"], out["issues"], out["links"])
return 200, out
```
В `src/jira_hub/mcp_server.py` в `fmt_issue` заменить строку `return "\n".join(head + ...` (две строки до конца
функции) на:
```python
comments = d.get("comments") or []
if comments:
shown = [f" {_v(c.get('author'))} {_v(c.get('created'))}: {_cut(c.get('body'), 300)}" for c in comments[:5]]
cmts = [f"комментарии ({len(comments)}):"] + shown + ([f" ещё {len(comments) - 5}"] if len(comments) > 5 else [])
else:
cmts = ["комментариев нет"]
return "\n".join(head + ["связи:"] + (links or [" —"]) + ["заметки:"] + (notes or [" —"]) + cmts
+ ["команды:"] + (cmds or [" —"]) + ["описание:", d.get("description") or "—"])
```
- [ ] **Step 5: Весь набор зелёный**
Run: `uv run pytest -q`
Expected: все тесты зелёные.
- [ ] **Step 6: Commit**
```bash
git add src/jira_hub/api.py src/jira_hub/mcp_server.py tests/test_api.py tests/test_mcp.py
git commit -m "feat(hub): проверка комментариев в POST /agent/state (422 bad_state), комментарии в MCP issue (#37)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
### Task 3: Данные карточки во фронте: типы, группы связей, просрочка, состояние окна
**Модель исполнителя:** haiku (готовый код).
**Files:**
- Modify: `web/src/api/types.ts` (`IssueDetail`, новые `IssueLinkOut`, `IssueLinkIn`, `IssueComment`)
- Create: `web/src/issues/card.ts`, `web/src/issues/card.test.ts`
- Create: `web/src/issues/modal.ts`, `web/src/issues/modal.test.ts`
- Create: `web/src/issues/commentsPref.ts`
- Create: `web/src/test/issueFixtures.ts`
- Modify: `web/src/test/setup.ts` (замена `showModal`/`close` для jsdom)
- Modify: `web/src/issues/IssuePage.test.tsx` (фабрика из `issueFixtures.ts`)
**Interfaces:**
- Produces (`card.ts`): `type LinkGroupId = "depends" | "blocks" | "related" | "other"`;
`interface CardLink { key: string; summary: string | null; status: string | null; tag: string | null }`;
`groupLinks(d: Pick<IssueDetail, "links_out" | "links_in">, linkTypes: Record<string, string>): [LinkGroupId, string, CardLink[]][]`;
`factClass(fact: string | null | undefined, statusLabels: Record<string, string> | undefined): string | null`;
`isOverdue(due: string, fact: string | null | undefined, statusLabels: Record<string, string> | undefined, today: string): boolean`;
`todayISO(d?: Date): string`.
- Produces (`modal.ts`): `interface Background { pathname: string; search: string }`;
`interface CardState { background: Background; depth: number; direct: boolean }`; `LIST_BACKGROUND`;
`BOARD_CARD_STATE: CardState`; `cardState(s: unknown): CardState | null`;
`openCardState(loc: { pathname: string; search: string; state: unknown }): CardState`.
- Produces (`commentsPref.ts`): `useCommentsHidden(): [boolean, (hidden: boolean) => void]`, ключ `COMMENTS_HIDDEN_KEY`.
- Produces (`test/issueFixtures.ts`): `note(over?)`, `issue(key, over?)` — `IssueDetail` для тестов.
- [ ] **Step 1: Типы**
В `web/src/api/types.ts` заменить `IssueDetail` на:
```ts
export interface IssueLinkOut { target_key: string; link_type: string; summary: string | null; status: string | null }
export interface IssueLinkIn { source_key: string; link_type: string; summary: string | null; status: string | null }
// комментарий задачи из Jira (зеркало хаба, агент v10+): текст и время — как в Jira
export interface IssueComment { comment_id: string; author: string | null; created: string | null; updated: string | null; body: string }
export interface IssueDetail extends IssueRow {
epic_summary: string | null;
links_out: IssueLinkOut[];
links_in: IssueLinkIn[];
note: Note | null;
commands: IssueCommand[];
comments: IssueComment[];
}
```
- [ ] **Step 2: Общие тестовые данные и `<dialog>` в jsdom**
Создать `web/src/test/issueFixtures.ts`:
```ts
import type { IssueDetail, Note } from "../api/types.ts";
export const note = (over: Partial<Note> = {}): Note => ({
key: "AURORA-9001", fact_status: "В работе", work_kind: "разработка", done_text: "схема", rest_text: "API",
owner_note: null, updated_at: "2026-10-14T09:00:00+00:00", updated_by: "chat", ...over,
});
export const issue = (key: string, over: Partial<IssueDetail> = {}): IssueDetail => ({
key, id: "1", issue_type: "Задача", summary: `Тема ${key}`, description: "строка 1\nстрока 2", status: "В работе",
priority: "Высокий", assignee: "Иванов Иван Иванович", reporter: "me", duedate: null, planned_start: null,
planned_end: "2026-10-20", epic_key: "AURORA-9100", epic_summary: "Тестовый эпик", labels: ["kind-dev"],
sprint: "AWPCA_Спринт 2", created: null, jira_updated: null, fetched_at: "", neighbor: false,
links_out: [{ target_key: "AURORA-9002", link_type: "ASUPR_Блокер_Начала", summary: "API свода", status: "Новая" }],
links_in: [{ source_key: "AURORA-9003", link_type: "Duplicate", summary: "Макет экрана", status: "Готово" }],
note: note({ key }),
commands: [{ id: 5, batch_id: 2, type: "ensure_fields", status: "done", finished_at: "2026-10-14T09:00:00+00:00", error: null }],
comments: [],
...over,
});
```
В `web/src/issues/IssuePage.test.tsx` удалить локальные `const note = …` и `const issue = …` (строки 13–25) и добавить
импорт `import { issue, note } from "../test/issueFixtures.ts";`.
В `web/src/test/setup.ts` после импортов добавить:
```ts
// jsdom 29 не умеет showModal/close у <dialog>: открытость — атрибут open, как в браузере
if (typeof HTMLDialogElement !== "undefined" && !HTMLDialogElement.prototype.showModal) {
HTMLDialogElement.prototype.showModal = function showModal(this: HTMLDialogElement) { this.setAttribute("open", ""); };
HTMLDialogElement.prototype.close = function close(this: HTMLDialogElement) { this.removeAttribute("open"); };
}
```
- [ ] **Step 3: Падающие тесты логики**
Создать `web/src/issues/card.test.ts`:
```ts
import { describe, expect, it } from "vitest";
import settings from "../board/__fixtures__/settings.json";
import { factClass, 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 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 });
describe("groupLinks", () => {
it("блокеры по направлению, связанные без повторов, прочие с направлением; порядок групп и ключей", () => {
const g = groupLinks({
links_out: [out("A-10", "ASUPR_Блокер_Завершения"), out("A-2", "ASUPR_Блокер_Начала"), out("A-5", "ASUPR_Связанные"),
out("A-7", "Duplicate")],
links_in: [inn("A-3", "ASUPR_Блокер_Начала"), inn("A-5", "ASUPR_Связанные"), inn("A-8", "Duplicate", null, null)],
}, types);
expect(g.map(([id, title]) => [id, title])).toEqual([["depends", "Зависит от"], ["blocks", "Блокирует"],
["related", "Связана с"], ["other", "Прочие"]]);
expect(g[0][2]).toEqual([{ key: "A-3", summary: "Тема A-3", status: "В работе", tag: "начало" }]);
expect(g[1][2].map((l) => [l.key, l.tag])).toEqual([["A-2", "начало"], ["A-10", "завершение"]]); // ключи как числа
expect(g[2][2].map((l) => l.key)).toEqual(["A-5"]);
expect(g[3][2]).toEqual([{ key: "A-7", summary: "Тема A-7", status: "В работе", tag: "→ Duplicate" },
{ key: "A-8", summary: null, status: null, tag: "← Duplicate" }]);
});
it("пустые группы не возвращаются; без link_types всё — «Прочие» с именем Jira", () => {
expect(groupLinks({ links_out: [], links_in: [] }, types)).toEqual([]);
const g = groupLinks({ links_out: [out("A-2", "ASUPR_Блокер_Начала")], links_in: [] }, {});
expect(g).toEqual([["other", "Прочие", [{ key: "A-2", summary: "Тема A-2", status: "В работе", tag: "→ ASUPR_Блокер_Начала" }]]]);
});
it("логический тип из настроек вне трёх известных — «Прочие» с логическим именем", () => {
const g = groupLinks({ links_out: [out("A-2", "X_Дубль")], links_in: [] }, { ...types, "дублирует": "X_Дубль" });
expect(g[0][2][0].tag).toBe("→ дублирует");
});
});
describe("просрочка и класс статуса по факту", () => {
it("класс из status_labels", () => {
expect(factClass("Готово — закрыть", labels)).toBe("fact-done");
expect(factClass("свой статус", labels)).toBeNull();
expect(factClass(null, labels)).toBeNull();
});
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("todayISO — местная дата YYYY-MM-DD", () => {
expect(todayISO(new Date(2026, 0, 5, 23, 30))).toBe("2026-01-05");
});
});
```
Создать `web/src/issues/modal.test.ts`:
```ts
import { describe, expect, it } from "vitest";
import { BOARD_CARD_STATE, cardState, LIST_BACKGROUND, openCardState } from "./modal.ts";
describe("состояние окна в истории", () => {
it("со страницы — фон = адрес страницы, глубина 1", () => {
expect(openCardState({ pathname: "/issues", search: "?sprint=S", state: null }))
.toEqual({ background: { pathname: "/issues", search: "?sprint=S" }, depth: 1, direct: false });
});
it("из окна — тот же фон, глубина + 1, признак прямого адреса сохраняется", () => {
const s = { background: LIST_BACKGROUND, depth: 0, direct: true };
expect(openCardState({ pathname: "/issues/A-1", search: "", state: s })).toEqual({ background: LIST_BACKGROUND, depth: 1, direct: true });
expect(openCardState({ pathname: "/issues/A-2", search: "", state: BOARD_CARD_STATE }))
.toEqual({ background: { pathname: "/board", search: "" }, depth: 2, direct: false });
});
it("чужое или битое state — не состояние окна", () => {
expect(cardState(null)).toBeNull();
expect(cardState({ background: "/issues", depth: 1 })).toBeNull();
expect(cardState({ background: { pathname: "/issues", search: "" }, depth: "1" })).toBeNull();
expect(cardState({ background: { pathname: "/issues", search: "" }, depth: 1 }))
.toEqual({ background: { pathname: "/issues", search: "" }, depth: 1, direct: false });
});
});
```
Run: `cd web && npx vitest run src/issues/card.test.ts src/issues/modal.test.ts`
Expected: FAIL — `Failed to resolve import "./card.ts"` и `"./modal.ts"`.
- [ ] **Step 4: Реализация логики**
Создать `web/src/issues/card.ts`:
```ts
import type { IssueDetail } from "../api/types.ts";
export type LinkGroupId = "depends" | "blocks" | "related" | "other";
export interface CardLink { key: string; summary: string | null; status: string | null; tag: string | null }
const GROUPS: [LinkGroupId, string][] = [["depends", "Зависит от"], ["blocks", "Блокирует"], ["related", "Связана с"], ["other", "Прочие"]];
const BLOCK_TAGS: Record<string, string> = { "блокирует начало": "начало", "блокирует завершение": "завершение" };
const byKey = (a: CardLink, b: CardLink) =>
a.key.localeCompare(b.key, "ru", { numeric: true }) || (a.tag ?? "").localeCompare(b.tag ?? "", "ru");
// связи карточки по смыслу (design 17): логическое имя типа — из link_types настроек (имя типа Jira → логическое)
export function groupLinks(d: Pick<IssueDetail, "links_out" | "links_in">, linkTypes: Record<string, string>): [LinkGroupId, string, CardLink[]][] {
const logical = new Map(Object.entries(linkTypes).map(([name, jira]) => [jira, name]));
const groups: Record<LinkGroupId, CardLink[]> = { depends: [], blocks: [], related: [], other: [] };
const add = (dir: "out" | "in", key: string, jiraType: string, summary: string | null, status: string | null) => {
const name = logical.get(jiraType);
if (name && BLOCK_TAGS[name]) groups[dir === "in" ? "depends" : "blocks"].push({ key, summary, status, tag: BLOCK_TAGS[name] });
else if (name === "связанные") { if (!groups.related.some((l) => l.key === key)) groups.related.push({ key, summary, status, tag: null }); }
else groups.other.push({ key, summary, status, tag: `${dir === "out" ? "→" : "←"} ${name ?? jiraType}` });
};
for (const l of d.links_out) add("out", l.target_key, l.link_type, l.summary, l.status);
for (const l of d.links_in) add("in", l.source_key, l.link_type, l.summary, l.status);
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));
}
export const todayISO = (d = new Date()): string =>
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
```
Создать `web/src/issues/modal.ts`:
```ts
// состояние окна карточки в записи истории (design 10–12): страница под окном, сколько записей заняли карточки
// поверх неё, открыто ли окно прямым адресом
export interface Background { pathname: string; search: string }
export interface CardState { background: Background; depth: number; direct: boolean }
export const LIST_BACKGROUND: Background = { pathname: "/issues", search: "" };
// доска не хранит состояние в адресе: «ⓘ» всегда открывает окно поверх /board
export const BOARD_CARD_STATE: CardState = { background: { pathname: "/board", search: "" }, depth: 1, direct: false };
export function cardState(s: unknown): CardState | null {
const c = s as { background?: { pathname?: unknown; search?: unknown }; depth?: unknown; direct?: unknown } | null;
if (!c || typeof c !== "object" || !c.background || typeof c.background !== "object") return null;
const { pathname, search } = c.background;
if (typeof pathname !== "string" || typeof search !== "string" || typeof c.depth !== "number") return null;
return { background: { pathname, search }, depth: c.depth, direct: c.direct === true };
}
// state ссылки на карточку: со страницы — фон = её адрес, глубина 1; из окна — тот же фон, глубина + 1
export function openCardState(loc: { pathname: string; search: string; state: unknown }): CardState {
const cur = cardState(loc.state);
return cur ? { ...cur, depth: cur.depth + 1 } : { background: { pathname: loc.pathname, search: loc.search }, depth: 1, direct: false };
}
```
Создать `web/src/issues/commentsPref.ts`:
```ts
import { useState } from "react";
// выбор «комментарии скрыты» — один на все карточки, в браузере (design 19); без localStorage — показаны
export const COMMENTS_HIDDEN_KEY = "jira-hub-comments-hidden";
const read = (): boolean => { try { return localStorage.getItem(COMMENTS_HIDDEN_KEY) === "1"; } catch { return false; } };
export function useCommentsHidden(): [boolean, (hidden: boolean) => void] {
const [hidden, setHidden] = useState(read);
const set = (v: boolean) => {
setHidden(v);
try { if (v) localStorage.setItem(COMMENTS_HIDDEN_KEY, "1"); else localStorage.removeItem(COMMENTS_HIDDEN_KEY); }
catch { /* хранилище недоступно: выбор живёт до закрытия карточки */ }
};
return [hidden, set];
}
```
- [ ] **Step 5: Тесты проходят, весь набор зелёный**
Run: `cd web && npx vitest run src/issues/card.test.ts src/issues/modal.test.ts`
Expected: PASS.
Run: `cd web && npm test && npm run lint && npx tsc -b`
Expected: все тесты зелёные (в том числе `IssuePage.test.tsx` с фабрикой из `issueFixtures.ts`), lint и tsc без ошибок.
- [ ] **Step 6: Commit**
```bash
git add web/src/api/types.ts web/src/issues/card.ts web/src/issues/card.test.ts web/src/issues/modal.ts \
web/src/issues/modal.test.ts web/src/issues/commentsPref.ts web/src/test/issueFixtures.ts web/src/test/setup.ts \
web/src/issues/IssuePage.test.tsx
git commit -m "feat(web): данные карточки — комментарии, группы связей, просрочка, состояние окна (#37)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
### Task 4: Карточка окном поверх страницы
**Модель исполнителя:** sonnet (интеграция с маршрутизатором).
**Files:**
- Create: `web/src/shell/pages.tsx` (`PAGE_ROUTES`)
- Modify: `web/src/routes.tsx`
- Modify: `web/src/shell/Layout.tsx`
- Create: `web/src/issues/IssueModal.tsx`, `web/src/issues/IssueLink.tsx`
- Rename: `web/src/issues/IssuePage.tsx` → `web/src/issues/IssueCard.tsx`; `web/src/issues/IssuePage.test.tsx` → `web/src/issues/IssueCard.test.tsx`
- Modify: `web/src/issues/IssuesPage.tsx:66` (ключ — `IssueLink`), `web/src/board/components/Card.tsx:34-35` (ⓘ — `BOARD_CARD_STATE`)
- Modify: `web/src/issues/issues.css` (окно)
- Test: `web/src/issues/IssueModal.test.tsx`, `web/src/App.test.tsx`, `web/src/board/components/BoardCanvas.test.tsx:53` (если проверяет только `href` — без изменений)
**Interfaces:**
- Consumes: Task 3 — `cardState`, `openCardState`, `LIST_BACKGROUND`, `BOARD_CARD_STATE`, `issue()`/`note()`.
- Produces: `IssueCard({ issueKey, onClose }: { issueKey: string; onClose: () => void })` (Task 5 переписывает его
содержимое, сигнатура та же); `IssueModal({ issueKey, onClose })`; `IssueLink({ issueKey, className?, children? })`;
`PAGE_ROUTES: RouteObject[]`; заголовок карточки — `<h2 id="issue-modal-title">`.
- [ ] **Step 1: Перенос страницы карточки в компонент окна**
```bash
git mv web/src/issues/IssuePage.tsx web/src/issues/IssueCard.tsx
git mv web/src/issues/IssuePage.test.tsx web/src/issues/IssueCard.test.tsx
```
В `web/src/issues/IssueCard.tsx`:
- заменить `import { Link, useParams } from "react-router";` на `import { IssueLink } from "./IssueLink.tsx";`;
- заменить `export function IssuePage() {` и строку `const { key = "" } = useParams();` на
`export function IssueCard({ issueKey: key, onClose }: { issueKey: string; onClose: () => void }) {`;
- `data-testid="page-issue"` везде заменить на `data-testid="issue-card"`;
- ветку 404 заменить на
`return <div className="issue" data-testid="issue-card"><p>Задачи {key} нет в зеркале хаба.</p><button className="btn" type="button" onClick={onClose}>Закрыть</button></div>;`;
- строку `<p><Link to="/issues">← задачи</Link></p>` заменить на
`<p><button className="btn sm" type="button" aria-label="Закрыть" onClick={onClose}>✕</button></p>`;
- `<h1>{i.key} · {i.summary}</h1>` заменить на `<h2 id="issue-modal-title">{i.key} · {i.summary}</h2>`;
- в связях `<Link to={`/issues/${l.target_key}`}>{l.target_key}</Link>` → `<IssueLink issueKey={l.target_key} />`,
`<Link to={`/issues/${l.source_key}`}>{l.source_key}</Link>` → `<IssueLink issueKey={l.source_key} />`.
В `web/src/issues/NoteForm.tsx` у блока подтверждения ухода `<div className="confirm" role="dialog">` заменить
`role="dialog"` на `role="alertdialog"`: внутри окна `<dialog>` второй `role="dialog"` делает поиск окна
(`getByRole("dialog")`) неоднозначным.
Создать `web/src/issues/IssueLink.tsx`:
```tsx
import type { ReactNode } from "react";
import { Link, useLocation } from "react-router";
import { openCardState } from "./modal.ts";
// ссылка на карточку задачи: открывает окно поверх текущей страницы (или меняет содержимое открытого окна)
export function IssueLink({ issueKey, className, children }: { issueKey: string; className?: string; children?: ReactNode }) {
const loc = useLocation();
return <Link className={className} to={`/issues/${encodeURIComponent(issueKey)}`} state={openCardState(loc)}>{children ?? issueKey}</Link>;
}
```
Создать `web/src/issues/IssueModal.tsx`:
```tsx
import { useEffect, useRef } from "react";
import { ErrorBoundary } from "../shell/ErrorBoundary.tsx";
import { IssueCard } from "./IssueCard.tsx";
// окно карточки (design 13): <dialog> держит фокус и делает фон недоступным; Esc и щелчок по фону — onClose.
// Сами окно не закрываем: оно исчезает вместе с адресом карточки. Если браузер закрыл его сам (Esc без активации
// пользователя — cancel не отменяемый), открываем снова и просим закрытия по общему пути (блокировщик заметок).
export function IssueModal({ issueKey, onClose }: { issueKey: string; onClose: () => void }) {
const ref = useRef<HTMLDialogElement>(null);
useEffect(() => {
const d = ref.current;
if (d && !d.open) d.showModal();
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden"; // страница под окном не прокручивается
return () => { document.body.style.overflow = prev; };
}, []);
return (
<dialog ref={ref} className="issue-modal" aria-labelledby="issue-modal-title"
onCancel={(e) => { if (e.cancelable) { e.preventDefault(); onClose(); } }}
onClose={() => { const d = ref.current; if (d?.isConnected) { d.showModal(); onClose(); } }}
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
<ErrorBoundary key={issueKey}><IssueCard issueKey={issueKey} onClose={onClose} /></ErrorBoundary>
</dialog>
);
}
```
- [ ] **Step 2: Маршруты и оболочка**
Создать `web/src/shell/pages.tsx`:
```tsx
import { Navigate, type RouteObject } from "react-router";
import { BoardPage } from "../board/BoardPage.tsx";
import { IssuesPage } from "../issues/IssuesPage.tsx";
import { NotFound } from "./NotFound.tsx";
// страницы приложения; Layout рисует их по адресу страницы под окном карточки (design 10)
export const PAGE_ROUTES: RouteObject[] = [
{ path: "/", element: <Navigate to="/board" replace /> },
{ path: "/board", element: <BoardPage /> },
{ path: "/issues", element: <IssuesPage /> },
{ path: "*", element: <NotFound /> },
];
```
Заменить `web/src/routes.tsx` целиком:
```tsx
import type { RouteObject } from "react-router";
import { Layout } from "./shell/Layout.tsx";
// адреса относительно basename "/app": страницы и окно карточки рисует Layout (shell/pages.tsx, design 10)
export const routes: RouteObject[] = [{ path: "*", element: <Layout /> }];
```
Заменить `web/src/shell/Layout.tsx` целиком:
```tsx
import { useQueryClient } from "@tanstack/react-query";
import { useCallback, useEffect, useMemo, useRef } from "react";
import { Link, matchPath, useLocation, useNavigate, useRoutes } from "react-router";
import { clearToken } from "../auth/token.ts";
import { IssueModal } from "../issues/IssueModal.tsx";
import { cardState, LIST_BACKGROUND } from "../issues/modal.ts";
import { ErrorBoundary } from "./ErrorBoundary.tsx";
import { PAGE_ROUTES } from "./pages.tsx";
import "./shell.css";
export function Layout() {
const qc = useQueryClient();
const location = useLocation();
const navigate = useNavigate();
const issueKey = matchPath("/issues/:key", location.pathname)?.params.key ?? null;
const card = issueKey ? cardState(location.state) : null;
const bgPath = issueKey ? (card?.background ?? LIST_BACKGROUND).pathname : null;
const bgSearch = issueKey ? (card?.background ?? LIST_BACKGROUND).search : "";
// адрес страницы под окном; передаётся в useRoutes всегда (и без окна), чтобы дерево страницы не менялось при
// открытии окна и useSearchParams внутри неё видел её собственный адрес (design 10)
const bgLocation = useMemo(() => bgPath === null ? null : { pathname: bgPath, search: bgSearch, hash: "", state: null, key: "card-background" },
[bgPath, bgSearch]);
const pageLocation = bgLocation ?? location;
const page = useRoutes(PAGE_ROUTES, pageLocation);
// прямой адрес карточки: запись истории получает state окна поверх списка задач (design 11)
useEffect(() => {
if (issueKey && !card) {
void navigate(location.pathname + location.search, { replace: true, state: { background: LIST_BACKGROUND, depth: 0, direct: true } });
}
}, [issueKey, card, location.pathname, location.search, navigate]);
// закрытие (design 12): назад на depth записей; запись прямого адреса, на которую вернулись, заменяется списком
const closingDirect = useRef(false);
useEffect(() => {
if (closingDirect.current && card?.direct && card.depth === 0) {
closingDirect.current = false;
void navigate("/issues", { replace: true });
}
}, [card, navigate]);
const close = useCallback(() => {
if (!card || card.depth === 0) { void navigate("/issues", { replace: true }); return; }
closingDirect.current = card.direct;
void navigate(-card.depth);
}, [card, navigate]);
const at = pageLocation.pathname;
const tab = (to: string, label: string) => {
const active = at === to || at.startsWith(to + "/");
return <Link to={to} className={active ? "active" : undefined} aria-current={active ? "page" : undefined}>{label}</Link>;
};
return (
<>
<header className="appbar">
<span className="brand">Хаб Jira</span>
<nav>{tab("/board", "Доска")}{tab("/issues", "Задачи")}</nav>
<span className="spacer" />
<button type="button" className="link" onClick={() => { clearToken(); qc.clear(); }}>Сменить токен</button>
</header>
<main className="page">
<ErrorBoundary key={at}>{page}</ErrorBoundary>
</main>
{issueKey && <IssueModal issueKey={issueKey} onClose={close} />}
</>
);
}
```
В `web/src/issues/IssuesPage.tsx`: импорт `Link` из `react-router` убрать (оставить `useSearchParams`), добавить
`import { IssueLink } from "./IssueLink.tsx";`, в строке таблицы `<Link to={`/issues/${i.key}`}>{i.key}</Link>`
заменить на `<IssueLink issueKey={i.key} />`.
В `web/src/board/components/Card.tsx` добавить импорт `import { BOARD_CARD_STATE } from "../../issues/modal.ts";` и у
ссылки «ⓘ» добавить атрибут `state={BOARD_CARD_STATE}` (после `to={`/issues/${n.key}`}`).
В `web/src/issues/issues.css` добавить в конец:
```css
/* окно карточки задачи (design 13) */
.issue-modal{width:min(1000px,calc(100vw - 32px));max-width:none;max-height:90vh;padding:0;border:1px solid var(--line);border-radius:10px;background:var(--bg);color:var(--fg);overflow:auto}
.issue-modal::backdrop{background:rgb(0 0 0 / .45)}
.issue-modal > .issue{padding:0 20px 20px}
@media (max-width:700px){.issue-modal{width:100vw;max-width:100vw;height:100vh;max-height:100vh;margin:0;border-radius:0}}
```
- [ ] **Step 3: Падающие тесты окна**
Создать `web/src/issues/IssueModal.test.tsx`:
```tsx
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { createMemoryRouter } from "react-router";
import { beforeEach, describe, expect, it } from "vitest";
import { App } from "../App.tsx";
import { setToken } from "../auth/token.ts";
import board from "../board/__fixtures__/hub-basic.board.json";
import settings from "../board/__fixtures__/settings.json";
import { routes } from "../routes.tsx";
import { json, mockFetch } from "../test/fetchMock.ts";
import { issue } from "../test/issueFixtures.ts";
const rows = board.issues.map((i) => ({ ...i, id: i.key, description: null, reporter: null, created: null, jira_updated: null, fetched_at: "" }));
function hub() {
return mockFetch((_m, url) => {
const p = url.pathname;
if (p === "/settings") return json(200, settings);
if (p === "/board") return json(200, board);
if (p === "/issues") return json(200, { issues: rows });
if (p.startsWith("/issues/")) return json(200, issue(decodeURIComponent(p.slice("/issues/".length))));
if (p === "/batches") return json(200, { batches: [] });
return json(200, {});
});
}
const at = (entries: string[], index = entries.length - 1) =>
createMemoryRouter(routes, { initialEntries: entries, initialIndex: index, basename: "/app" });
const dialog = () => screen.getByRole("dialog");
describe("карточка окном", () => {
beforeEach(() => { setToken("tok"); hub(); });
it("из списка: окно поверх списка, фон не пересоздаётся, ✕ возвращает со старым фильтром, «назад» не открывает карточку", async () => {
const router = at(["/app/board", "/app/issues?sprint=AWPCA_Спринт 2"]);
render(<App router={router} />);
const table = await screen.findByRole("table");
expect(screen.getByLabelText("Спринт")).toHaveValue("AWPCA_Спринт 2");
await userEvent.click(await within(table).findByRole("link", { name: "AURORA-9002" }));
expect(router.state.location.pathname).toBe("/app/issues/AURORA-9002");
expect(await within(dialog()).findByRole("heading", { name: /AURORA-9002/ })).toBeInTheDocument();
expect(screen.getByRole("table")).toBe(table); // фон не пересоздаётся
expect(screen.getByLabelText("Спринт")).toHaveValue("AWPCA_Спринт 2"); // фильтр фона — из его адреса
expect(screen.getByRole("link", { name: "Задачи", exact: true })).toHaveClass("active");
await userEvent.click(within(dialog()).getByRole("button", { name: "Закрыть" }));
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
expect(router.state.location.pathname).toBe("/app/issues");
expect(new URLSearchParams(router.state.location.search).get("sprint")).toBe("AWPCA_Спринт 2");
expect(screen.getByRole("table")).toBe(table);
await act(async () => { await router.navigate(-1); });
expect(router.state.location.pathname).toBe("/app/board");
});
it("переход по связи меняет окно, «назад» возвращает прежнюю карточку, Esc закрывает всё", async () => {
const router = at(["/app/issues"]);
render(<App router={router} />);
await userEvent.click(await screen.findByRole("link", { name: "AURORA-9001" }));
await userEvent.click(await within(dialog()).findByRole("link", { name: "AURORA-9002" }));
expect(await within(dialog()).findByRole("heading", { name: /AURORA-9002/ })).toBeInTheDocument();
await act(async () => { await router.navigate(-1); });
expect(await within(dialog()).findByRole("heading", { name: /AURORA-9001/ })).toBeInTheDocument();
await userEvent.click(within(dialog()).getByRole("link", { name: "AURORA-9002" }));
await within(dialog()).findByRole("heading", { name: /AURORA-9002/ });
fireEvent(dialog(), new Event("cancel", { cancelable: true }));
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
expect(router.state.location.pathname).toBe("/app/issues");
});
it("щелчок по фону окна закрывает его", async () => {
const router = at(["/app/issues"]);
render(<App router={router} />);
await userEvent.click(await screen.findByRole("link", { name: "AURORA-9001" }));
await within(dialog()).findByRole("heading", { name: /AURORA-9001/ });
await userEvent.click(dialog()); // цель — сам <dialog>, не содержимое
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
expect(router.state.location.pathname).toBe("/app/issues");
});
it("с доски: окно поверх доски, вкладка «Доска» активна, после закрытия доска та же", async () => {
const router = at(["/app/board"]);
render(<App router={router} />);
const ilink = await screen.findByRole("link", { name: "Открыть карточку AURORA-9001" });
const boardEl = screen.getByTestId("page-board");
await userEvent.click(ilink);
expect(await within(dialog()).findByRole("heading", { name: /AURORA-9001/ })).toBeInTheDocument();
expect(screen.getByTestId("page-board")).toBe(boardEl);
expect(screen.getByRole("link", { name: "Доска", exact: true })).toHaveClass("active");
await userEvent.click(within(dialog()).getByRole("button", { name: "Закрыть" }));
await waitFor(() => expect(router.state.location.pathname).toBe("/app/board"));
expect(screen.getByTestId("page-board")).toBe(boardEl);
});
it("прямой адрес: под окном список задач, ✕ ведёт в список заменой записи", async () => {
const router = at(["/app/board", "/app/issues/AURORA-9001"]);
render(<App router={router} />);
expect(await within(await screen.findByRole("dialog")).findByRole("heading", { name: /AURORA-9001/ })).toBeInTheDocument();
expect(await screen.findByTestId("page-issues")).toBeInTheDocument();
await waitFor(() => expect(router.state.location.state).toMatchObject({ depth: 0, direct: true }));
await userEvent.click(within(dialog()).getByRole("button", { name: "Закрыть" }));
await waitFor(() => expect(router.state.location.pathname).toBe("/app/issues"));
await act(async () => { await router.navigate(-1); });
expect(router.state.location.pathname).toBe("/app/board"); // не карточка
});
it("прямой адрес и переход по связи: закрытие возвращает в список, а не на первую карточку", async () => {
const router = at(["/app/issues/AURORA-9001"]);
render(<App router={router} />);
await userEvent.click(await within(await screen.findByRole("dialog")).findByRole("link", { name: "AURORA-9002" }));
await within(dialog()).findByRole("heading", { name: /AURORA-9002/ });
fireEvent(dialog(), new Event("cancel", { cancelable: true }));
await waitFor(() => expect(router.state.location.pathname).toBe("/app/issues"));
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
});
it("Esc с несохранённой заметкой спрашивает; «Остаться» оставляет окно с текстом", async () => {
const router = at(["/app/issues"]);
render(<App router={router} />);
await userEvent.click(await screen.findByRole("link", { name: "AURORA-9001" }));
const box = await within(dialog()).findByLabelText("Заметка владельца");
await userEvent.type(box, "черновик");
fireEvent(dialog(), new Event("cancel", { cancelable: true }));
expect(await within(dialog()).findByText("Есть несохранённые правки заметки. Уйти без сохранения?")).toBeInTheDocument();
await userEvent.click(within(dialog()).getByRole("button", { name: "Остаться" }));
expect(router.state.location.pathname).toBe("/app/issues/AURORA-9001");
expect(within(dialog()).getByLabelText("Заметка владельца")).toHaveValue("черновик");
});
});
```
В `web/src/issues/IssueCard.test.tsx`:
- импорт `import { IssuePage } from "./IssuePage.tsx";` заменить на `import { IssueCard } from "./IssueCard.tsx";` и
добавить `useNavigate, useParams` в импорт из `react-router`;
- в `open()` маршрут `{ path: "/issues/:key", element: <IssuePage /> }` заменить на
`{ path: "/issues/:key", element: <CardAt /> }` и перед `open()` добавить:
```tsx
function CardAt() {
const { key = "" } = useParams();
const navigate = useNavigate();
return <IssueCard issueKey={key} onClose={() => void navigate("/board")} />;
}
```
- тест «заголовок страницы — h1 (бренд в оболочке — span)» заменить на:
```tsx
it("заголовок карточки — h2 с id для aria-labelledby окна", async () => {
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001")));
open("/issues/AURORA-9001");
expect(await screen.findByRole("heading", { level: 2, name: /AURORA-9001/ })).toHaveAttribute("id", "issue-modal-title");
});
```
- тест «задачи нет в зеркале — сообщение и ссылка на список» заменить на:
```tsx
it("задачи нет в зеркале — сообщение и «Закрыть»", async () => {
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(404, { error: "not_found", detail: "issue" }));
const router = open("/issues/AURORA-1");
expect(await screen.findByText("Задачи AURORA-1 нет в зеркале хаба.")).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Закрыть" }));
expect(router.state.location.pathname).toBe("/board");
});
```
- тест «O-10: ссылка в ветке «задачи нет в зеркале» стоит внутри .issue (цвет темы)» удалить (ссылки в ветке 404 больше
нет; цвет ссылок `.issue a` проверяет Task 5).
В `web/src/App.test.tsx` в тесте «неизвестный путь — «не найдено» со ссылками» ожидания не меняются (NotFound — в
`PAGE_ROUTES`); если тест обращается к `Layout` напрямую — оставить импорт (`Layout` экспортируется по-прежнему).
Run: `cd web && npx vitest run src/issues/IssueModal.test.tsx src/issues/IssueCard.test.tsx src/App.test.tsx`
Expected до Step 2 — FAIL (нет `IssueModal`, `routes` без окна); после Step 1–2 — проверить следующим шагом.
- [ ] **Step 4: Тесты проходят, весь набор зелёный**
Run: `cd web && npx vitest run src/issues/IssueModal.test.tsx src/issues/IssueCard.test.tsx src/App.test.tsx`
Expected: PASS.
Run: `cd web && npm test && npm run lint && npx tsc -b`
Expected: всё зелёное. `BoardCanvas.test.tsx` проверяет `href="/issues/AURORA-9001"` у «ⓘ» — `state` на `href` не влияет.
Если `useRoutes` с явным адресом пересоздаёт страницу при открытии окна (тест «фон не пересоздаётся» падает на
`toBe(table)`), это нарушение design 10: не обходить ключами `key`, а остановиться и описать наблюдение в отчёте.
- [ ] **Step 5: Commit**
```bash
git add web/src/routes.tsx web/src/shell/Layout.tsx web/src/shell/pages.tsx web/src/issues web/src/board/components/Card.tsx \
web/src/App.test.tsx
git commit -m "feat(web): карточка задачи окном поверх списка и доски (#37)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
### Task 5: Раскладка карточки и заметки в режиме чтения
**Модель исполнителя:** sonnet (вёрстка и перенос тестов).
**Files:**
- Modify: `web/src/issues/IssueCard.tsx` (полностью), `web/src/issues/NoteForm.tsx` (`onDone`, «Отмена»)
- Create: `web/src/issues/Notes.tsx`
- Modify: `web/src/issues/issues.css` (раскладка карточки)
- Test: `web/src/issues/IssueCard.test.tsx`, `web/src/issues/IssueModal.test.tsx`
**Interfaces:**
- Consumes: Task 3 — `groupLinks`, `factClass`, `isOverdue`, `todayISO`, `useCommentsHidden`, `IssueComment`; Task 4 —
`IssueCard({ issueKey, onClose })`, `IssueLink`, `<h2 id="issue-modal-title">`.
- Produces: `Notes({ issue }: { issue: IssueDetail })`; `NoteForm({ issue, onDone }: { issue: IssueDetail; onDone: () => void })`.
- [ ] **Step 1: Падающие тесты раскладки**
В `web/src/issues/IssueCard.test.tsx`:
1) Тест «поля, описание с переносами, связи с подписями, команды» заменить на:
```tsx
it("шапка: ключ, статусы, тема, исполнитель · срок · спринт · эпик по имени; ссылка в 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");
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");
});
it("связи по группам с темой и статусом; задача вне зеркала; прочий тип с направлением", async () => {
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", {
links_out: [{ target_key: "AURORA-9002", link_type: "ASUPR_Блокер_Начала", summary: "API свода", status: "Новая" },
{ target_key: "lower-1", link_type: "ASUPR_Связанные", summary: null, status: null }],
links_in: [{ source_key: "AURORA-9003", link_type: "ASUPR_Блокер_Завершения", summary: "Макет экрана", status: "Готово" },
{ source_key: "AURORA-9008", link_type: "Duplicate", summary: "Актуализация ТЗ", status: "В работе" }],
})));
open("/issues/AURORA-9001");
const depends = await screen.findByRole("group", { name: "Зависит от" });
expect(within(depends).getByRole("listitem")).toHaveTextContent("завершение AURORA-9003 Макет экрана Готово");
const blocks = screen.getByRole("group", { name: "Блокирует" });
expect(within(blocks).getByRole("link", { name: "AURORA-9002" })).toHaveAttribute("href", "/issues/AURORA-9002");
expect(within(blocks).getByRole("listitem")).toHaveTextContent("начало AURORA-9002 API свода Новая");
expect(within(screen.getByRole("group", { name: "Связана с" })).getByRole("listitem")).toHaveTextContent("lower-1 нет в зеркале");
expect(within(screen.getByRole("group", { name: "Прочие" })).getByRole("listitem")).toHaveTextContent("← Duplicate AURORA-9008");
expect(screen.getByRole("link", { name: "AURORA-9002" }).closest(".issue")).not.toBeNull(); // O-10: цвет темы
});
it("без связей — «связей нет»", async () => {
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { links_out: [], links_in: [] })));
open("/issues/AURORA-9001");
expect(await screen.findByText("связей нет")).toBeInTheDocument();
});
it("описание свыше 8 строк свёрнуто, «развернуть»/«свернуть»; короткое — без кнопки", async () => {
const long = Array.from({ length: 10 }, (_, n) => `строка ${n + 1}`).join("\n");
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { description: long })));
open("/issues/AURORA-9001");
const pre = await screen.findByText(long, { normalizer: (s) => s });
expect(pre).toHaveClass("description", "clamped");
await userEvent.click(screen.getByRole("button", { name: "развернуть" }));
expect(pre).not.toHaveClass("clamped");
await userEvent.click(screen.getByRole("button", { name: "свернуть" }));
expect(pre).toHaveClass("clamped");
});
it("короткое описание — без кнопки; нет описания — нет раздела", async () => {
let d: string | null = "строка 1\nстрока 2";
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { description: d })));
open("/issues/AURORA-9001");
expect(await screen.findByText("строка 1\nстрока 2", { normalizer: (s) => s })).toHaveClass("description");
expect(screen.queryByRole("button", { name: "развернуть" })).toBeNull();
d = null;
cleanup();
open("/issues/AURORA-9001");
await screen.findByRole("heading", { level: 2 });
expect(screen.queryByRole("heading", { name: "Описание" })).toBeNull();
});
it("комментарии новые сверху; «Скрыть» запоминается для следующей карточки", async () => {
const comments = [
{ comment_id: "2", author: "Петров Пётр", created: "2026-10-15T12:00:00.000+0300", updated: null, body: "новый\nвторая строка" },
{ comment_id: "1", author: "Иванов Иван", created: "2026-10-14T12:00:00.000+0300", updated: null, body: "старый" }];
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { comments })));
open("/issues/AURORA-9001");
expect(await screen.findByRole("heading", { name: "Комментарии (2)" })).toBeInTheDocument();
const items = within(screen.getByTestId("comments")).getAllByRole("listitem");
expect(items[0]).toHaveTextContent("Петров Пётр");
expect(within(items[0]).getByText("новый\nвторая строка", { normalizer: (s) => s })).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Скрыть" }));
expect(screen.queryByText("старый")).toBeNull();
expect(localStorage.getItem("jira-hub-comments-hidden")).toBe("1");
cleanup();
open("/issues/AURORA-9001");
expect(await screen.findByRole("button", { name: "Показать" })).toBeInTheDocument();
expect(screen.queryByText("старый")).toBeNull();
});
it("комментарий без автора и текста не ломает карточку; без комментариев — «комментариев нет»", async () => {
let comments = [{ comment_id: "1", author: null, created: null, updated: null, body: "" }];
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { comments })));
open("/issues/AURORA-9001");
expect(await screen.findByRole("heading", { name: "Комментарии (1)" })).toBeInTheDocument();
expect(within(screen.getByTestId("comments")).getByRole("listitem")).toHaveTextContent("—");
comments = [];
cleanup();
open("/issues/AURORA-9001");
expect(await screen.findByText("комментариев нет")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Скрыть" })).toBeNull();
});
it("прочие поля и команды моста свёрнуты", async () => {
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001")));
open("/issues/AURORA-9001");
const other = await screen.findByRole("region", { name: "Прочие поля" });
expect(other).toHaveTextContent("Приоритет");
expect(other).toHaveTextContent("Высокий");
const details = screen.getByText("Команды моста (1)").closest("details")!;
expect(details).not.toHaveAttribute("open");
expect(within(details).getByRole("cell", { name: "ensure_fields" })).toBeInTheDocument();
});
```
(в импорт из `@testing-library/react` добавить `cleanup` и `within`).
2) Тест «O-11: задача без команд — текст вместо пустой таблицы» оставить, в нём ничего не менять.
3) Тесты заметок — перед первым обращением к полю формы добавить щелчок «Править»:
`await userEvent.click(await screen.findByRole("button", { name: "Править" }));` — в тестах «сохраняются только
изменённые поля…», «422 — текст ошибки…», «уход с несохранёнными правками…», «ответ «Сохранить» после ухода…»,
«T14.1…», «T14.3…». Дальше `findByLabelText(...)` остаётся. Ожидания после успешного сохранения в тесте «сохраняются
только изменённые поля» заменить на:
```tsx
await waitFor(() => expect(put).toEqual({ rest_text: "тесты", done_text: null, updated_by: "web" }));
expect(await screen.findByText(/изменено .*web/)).toBeInTheDocument(); // снова режим чтения
expect(screen.getByRole("button", { name: "Править" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Сохранить" })).toBeNull();
```
В тесте «ответ «Сохранить» после ухода в другую карточку…» после перехода на AURORA-9002 последние три ожидания заменить
на:
```tsx
expect(screen.getByRole("heading", { name: /AURORA-9002/ })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Править" })).toBeInTheDocument(); // новая карточка — в режиме чтения
expect(screen.queryByText("черновик")).toBeNull();
```
В тесте «T14.1…» после `slowPut.resolve(...)` ожидания заменить на:
```tsx
expect(await screen.findByText("раз")).toBeInTheDocument(); // режим чтения с сохранённым
```
4) В `web/src/issues/IssueModal.test.tsx`, тест «Esc с несохранённой заметкой спрашивает…», перед строкой
`const box = await within(dialog()).findByLabelText("Заметка владельца");` добавить
`await userEvent.click(await within(dialog()).findByRole("button", { name: "Править" }));`.
5) Добавить тесты режима чтения и «Отмены»:
```tsx
it("заметки в режиме чтения: пустые — «—», время и автор; «Отмена» без правок — сразу", async () => {
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001")));
open("/issues/AURORA-9001");
const view = await screen.findByRole("region", { name: "Служебные заметки" });
expect(view).toHaveTextContent("Сделано");
expect(view).toHaveTextContent("схема");
expect(within(view).getByText("Заметка владельца").nextElementSibling).toHaveTextContent("—");
expect(view).toHaveTextContent(/изменено .*chat/);
await userEvent.click(within(view).getByRole("button", { name: "Править" }));
await userEvent.click(screen.getByRole("button", { name: "Отмена" }));
expect(screen.getByRole("button", { name: "Править" })).toBeInTheDocument();
});
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: "Править" }));
await userEvent.type(screen.getByLabelText("Заметка владельца"), "черновик");
await userEvent.click(screen.getByRole("button", { name: "Отмена" }));
expect(screen.getByText("Отменить правки заметки?")).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Продолжить правку" }));
expect(screen.getByLabelText("Заметка владельца")).toHaveValue("черновик");
await userEvent.click(screen.getByRole("button", { name: "Отмена" }));
await userEvent.click(screen.getByRole("button", { name: "Отменить правки" }));
expect(screen.getByRole("button", { name: "Править" })).toBeInTheDocument();
expect(screen.queryByText("черновик")).toBeNull();
});
it("заметки ещё нет — режим чтения с «—» и «Править»", async () => {
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).getByRole("button", { name: "Править" })).toBeInTheDocument();
});
```
Run: `cd web && npx vitest run src/issues/IssueCard.test.tsx`
Expected: FAIL — нет `issue-head`, групп связей, раздела «Комментарии», кнопки «Править».
- [ ] **Step 2: Заметки: чтение и правка**
Создать `web/src/issues/Notes.tsx`:
```tsx
import { useState } from "react";
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", "Сделано"],
["rest_text", "Осталось"], ["owner_note", "Заметка владельца"]];
// служебные заметки (design 20): по умолчанию чтение, «Править» — форма; после сохранения или отмены — снова чтение
export function Notes({ issue }: { issue: IssueDetail }) {
const [editing, setEditing] = useState(false);
if (editing) return <NoteForm issue={issue} onDone={() => setEditing(false)} />;
const n = issue.note;
return (
<section className="note view" aria-labelledby="notes-title">
<div className="sec-head">
<h3 id="notes-title">Служебные заметки</h3>
<button className="btn sm" type="button" onClick={() => setEditing(true)}>Править</button>
</div>
<dl className="fields">
{ROWS.map(([f, label]) => <div key={f}><dt>{label}</dt><dd>{n?.[f] || "—"}</dd></div>)}
</dl>
{n && <p className="hint">изменено {fmtStamp(n.updated_at)}, {n.updated_by}</p>}
</section>
);
}
```
В `web/src/issues/NoteForm.tsx`:
- в импорт из `react` добавить `useRef`;
- сигнатуру заменить на `export function NoteForm({ issue, onDone }: { issue: IssueDetail; onDone: () => void }) {`;
- после `const save = useSaveNote(issue.key);` добавить
`const [askCancel, setAskCancel] = useState(false);` и `const confirmRef = useRef<HTMLDivElement>(null);`;
- после эффекта `beforeunload` добавить:
```tsx
// подтверждение ухода может оказаться ниже видимой части окна — прокрутить к нему
useEffect(() => { if (blocker.state === "blocked") confirmRef.current?.scrollIntoView?.({ block: "nearest" }); }, [blocker.state]);
```
- `submit` заменить на:
```tsx
const submit = () => save.mutate(changed, {
onSuccess: (note) => { const v = valuesOf(note); setInitial(v); setValues(v); onDone(); },
});
```
- строку `const saved = save.data ?? issue.note;` удалить;
- `<form className="note" …>` заменить на `<form className="note" aria-labelledby="notes-title" …>`, а
`<h3>Служебные заметки</h3>` — на `<h3 id="notes-title">Служебные заметки</h3>`;
- блок `<div className="row">…</div>` (кнопка «Сохранить» и строка «сохранено …») заменить на:
```tsx
<div className="row">
<button className="btn" type="submit" disabled={!dirty || save.isPending}>Сохранить</button>
<button className="btn" type="button" disabled={save.isPending} onClick={() => (dirty ? setAskCancel(true) : onDone())}>Отмена</button>
</div>
{askCancel && (
<div className="confirm" role="alertdialog" aria-label="Отменить правки заметки?">
<span>Отменить правки заметки?</span>
<button className="btn" type="button" onClick={onDone}>Отменить правки</button>
<button className="btn" type="button" onClick={() => setAskCancel(false)}>Продолжить правку</button>
</div>
)}
```
- у блока подтверждения ухода `<div className="confirm" role="alertdialog">` (роль поменял Task 4) добавить
`ref={confirmRef}`.
- [ ] **Step 3: Карточка**
Заменить `web/src/issues/IssueCard.tsx` целиком:
```tsx
import { useLayoutEffect, useRef, useState } from "react";
import { ApiError } from "../api/client.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 { useCommentsHidden } from "./commentsPref.ts";
import { IssueLink } from "./IssueLink.tsx";
import { Notes } from "./Notes.tsx";
import { dueOf } from "./options.ts";
import "./issues.css";
const DESC_LINES = 8;
export function IssueCard({ issueKey: key, onClose }: { issueKey: string; onClose: () => void }) {
const issue = useIssue(key);
const settings = useSettings();
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) {
return <div className="issue" data-testid="issue-card"><p>Задачи {key} нет в зеркале хаба.</p><button className="btn" type="button" onClick={onClose}>Закрыть</button></div>;
}
if (issue.isError && !issue.data) {
return (
<div className="issue" data-testid="issue-card">
<p className="warn" role="alert">{issue.error.message}{" "}
<button className="btn sm" type="button" onClick={() => void issue.refetch()}>Повторить</button></p>
<button className="btn" type="button" onClick={onClose}>Закрыть</button>
</div>
);
}
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>,
i.sprint && <span key="sprint">{i.sprint}</span>,
i.epic_key && <span key="epic">эпик: {i.epic_summary ?? i.epic_key}</span>,
].filter(Boolean);
return (
<div className="issue" data-testid="issue-card">
<header className="issue-head" data-testid="issue-head">
<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}
</div>
<h2 id="issue-modal-title">{i.summary || i.key}</h2>
{facts.length > 0 && <p className="facts" data-testid="issue-facts">{facts.flatMap((f, n) => (n ? [" · ", f] : [f]))}</p>}
{issue.isError && (
<p className="warn" role="alert">{issue.error.message}{" "}
<button className="btn sm" type="button" onClick={() => void issue.refetch()}>Повторить</button></p>
)}
</header>
<div className="cols">
<div className="main">
<Links issue={i} linkTypes={(settings.data?.link_types ?? {}) as Record<string, string>} />
{i.description && <Description text={i.description} />}
<Comments items={i.comments ?? []} />
</div>
<div className="side">
<Notes key={i.key} issue={i} />
<OtherFields issue={i} />
<Commands issue={i} />
</div>
</div>
</div>
);
}
function Links({ issue, linkTypes }: { issue: IssueDetail; linkTypes: Record<string, string> }) {
const groups = groupLinks(issue, linkTypes);
return (
<section className="links-sec">
<h3>Связи</h3>
{!groups.length && <p className="hint">связей нет</p>}
{groups.map(([id, title, links]) => (
<div key={id} className="group" role="group" aria-labelledby={`links-${id}`}>
<h4 id={`links-${id}`}>{title}</h4>
<ul className="links">
{links.map((l) => (
<li key={id + l.key + (l.tag ?? "")}>
{l.tag && <><span className="tag">{l.tag}</span>{" "}</>}
<IssueLink issueKey={l.key} />{" "}
{l.summary === null && l.status === null
? <span className="sum absent">нет в зеркале</span>
: <><span className="sum">{l.summary}</span>{l.status && <>{" "}<span className="badge status">{l.status}</span></>}</>}
</li>
))}
</ul>
</div>
))}
</section>
);
}
function Description({ text }: { text: string }) {
const ref = useRef<HTMLPreElement>(null);
const [open, setOpen] = useState(false);
const [overflow, setOverflow] = useState(false);
useLayoutEffect(() => {
const el = ref.current;
if (el && !open) setOverflow(el.scrollHeight > el.clientHeight + 1);
}, [text, open]);
const long = text.split("\n").length > DESC_LINES || overflow;
return (
<section>
<h3>Описание</h3>
<pre ref={ref} className={"description" + (open ? "" : " clamped")}>{text}</pre>
{(long || open) && <button className="link" type="button" onClick={() => setOpen(!open)}>{open ? "свернуть" : "развернуть"}</button>}
</section>
);
}
function Comments({ items }: { items: IssueComment[] }) {
const [hidden, setHidden] = useCommentsHidden();
return (
<section className="comments" data-testid="comments">
<div className="sec-head">
<h3>Комментарии ({items.length})</h3>
{items.length > 0 && <button className="link" type="button" onClick={() => setHidden(!hidden)}>{hidden ? "Показать" : "Скрыть"}</button>}
</div>
{!items.length && <p className="hint">комментариев нет</p>}
{items.length > 0 && !hidden && (
<ul>
{items.map((c) => (
<li key={c.comment_id}>
<div className="who">{c.author ?? "—"}{c.created ? `, ${fmtStamp(c.created)}` : ""}</div>
<pre className="body">{c.body}</pre>
</li>
))}
</ul>
)}
</section>
);
}
function OtherFields({ issue: i }: { issue: IssueDetail }) {
const rows: [string, string | null][] = [
["Тип", i.issue_type], ["Приоритет", i.priority], ["Автор", i.reporter], ["Метки", i.labels.join(", ") || null],
["Плановое начало", i.planned_start], ["Обновлена в Jira", i.jira_updated ? fmtStamp(i.jira_updated) : null],
["Сосед по связям", i.neighbor ? "да" : null],
];
return (
<section className="other" aria-labelledby="other-title">
<h3 id="other-title">Прочие поля</h3>
<dl className="fields">{rows.filter(([, v]) => v).map(([k, v]) => <div key={k}><dt>{k}</dt><dd>{v}</dd></div>)}</dl>
</section>
);
}
function Commands({ issue: i }: { issue: IssueDetail }) {
if (!i.commands.length) {
return <section className="commands"><h3>Команды моста</h3><p className="hint">команд по задаче не было</p></section>;
}
return (
<section className="commands">
<details>
<summary>Команды моста ({i.commands.length})</summary>
<table className="grid">
<thead><tr><th>Команда</th><th>Пакет</th><th>Тип</th><th>Статус</th><th>Завершена</th><th>Ошибка</th></tr></thead>
<tbody>{i.commands.map((c) => (
<tr key={c.id}><td>#{c.id}</td><td>#{c.batch_id}</td><td>{c.type}</td><td>{c.status}</td>
<td>{c.finished_at ? fmtStamp(c.finished_at) : ""}</td><td>{c.error}</td></tr>
))}</tbody>
</table>
</details>
</section>
);
}
```
Тест O-11 ищет `screen.queryByRole("columnheader", { name: "Команда" })` = `null` — при пустых командах таблицы нет.
- [ ] **Step 4: Стили карточки**
В `web/src/issues/issues.css` заменить строки от `.issue a{color:var(--accent)}` до
`.issue .links{padding-left:18px}` включительно на:
```css
.issue a{color:var(--accent)}
.issue-head{position:sticky;top:0;z-index:1;background:var(--bg);padding:14px 0 10px;border-bottom:1px solid var(--line);margin-bottom:14px}
.issue-head .row1{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
.issue-head .key{font-family:var(--font-mono);font-size:13px;color:var(--muted)}
.issue-head .spacer{flex:1 1 auto}
.issue-head h2{margin:6px 0 4px;font-size:20px;line-height:1.3}
.issue-head .facts{margin:0;color:var(--muted);font-size:14px}
.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 .main{min-width:0}
@media (max-width:900px){
.issue .cols{display:flex;flex-direction:column;gap:16px}
.issue .side{display:contents}
.issue .side .note{order:-1}
.issue .side .other{order:1}
.issue .side .commands{order:2}
}
.issue section{margin-bottom:18px}
.issue h3{font-size:13px;text-transform:uppercase;letter-spacing:.04em;color:var(--muted);margin:0 0 8px}
.issue .sec-head{display:flex;align-items:center;justify-content:space-between;gap:8px}
.issue .group{margin:0 0 10px}
.issue .group h4{font-size:13px;font-weight:600;margin:0 0 4px}
.issue .links{list-style:none;margin:0;padding:0}
.issue .links li{display:flex;flex-wrap:wrap;align-items:baseline;gap:8px;padding:3px 0}
.issue .links .tag{font-size:12px;color:var(--muted);min-width:76px}
.issue .links .sum{flex:1 1 200px;min-width:0}
.issue .links .absent{color:var(--muted);font-style:italic}
.issue .description{white-space:pre-wrap;font:inherit;background:var(--panel);border:1px solid var(--line);border-radius:6px;padding:10px;margin:0 0 4px}
.issue .description.clamped{display:-webkit-box;-webkit-line-clamp:8;-webkit-box-orient:vertical;overflow:hidden}
.issue .comments ul{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:10px}
.issue .comments .who{font-size:12px;color:var(--muted);margin-bottom:2px}
.issue .comments .body{white-space:pre-wrap;font:inherit;margin:0}
.issue .fields{display:grid;grid-template-columns:1fr;gap:6px;margin:0}
.issue .fields dt{font-size:12px;color:var(--muted)}
.issue .fields dd{margin:0;white-space:pre-wrap}
.issue .commands summary{cursor:pointer;font-size:13px;color:var(--muted);text-transform:uppercase;letter-spacing:.04em}
.issue .commands table{margin-top:8px;font-size:13px}
```
- [ ] **Step 5: Тесты проходят, весь набор зелёный**
Run: `cd web && npx vitest run src/issues`
Expected: PASS (`IssueCard.test.tsx`, `IssueModal.test.tsx`, `card.test.ts`, `modal.test.ts`, `IssuesPage.test.tsx`).
Run: `cd web && npm test && npm run lint && npx tsc -b`
Expected: всё зелёное.
- [ ] **Step 6: Commit**
```bash
git add web/src/issues
git commit -m "feat(web): раскладка карточки — шапка состояния, связи по смыслу, комментарии, заметки в режиме чтения (#37)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
### Task 6: Тестовый хаб, e2e и документация
**Модель исполнителя:** sonnet (e2e и проверка в браузере).
**Files:**
- Create: `web/scripts/seed-extras.json`
- Modify: `web/scripts/seed-hub.py`
- Modify: `web/e2e/issues.spec.ts`, `web/e2e/board.spec.ts:56-81`
- Modify: `README.md` (раздел веб-приложения, строка 161), `ARCHITECTURE.md` (если описывает страницы `web/`)
**Interfaces:**
- Consumes: Tasks 1–5 (комментарии в зеркале, окно, раскладка).
- [ ] **Step 1: Дополнения тестового хаба**
Создать `web/scripts/seed-extras.json`:
```json
{
"issues": {
"AURORA-9001": {
"description": "Цель: схема свода для КФО.\nКонтекст: ТЗ 6.4.\nШаг 1 — таблицы.\nШаг 2 — связи.\nШаг 3 — индексы.\nШаг 4 — миграции.\nШаг 5 — проверки.\nШаг 6 — документация.\nШаг 7 — ревью.\nШаг 8 — выкатка.\nШаг 9 — приёмка.\nКонец описания.",
"comments": [
{"id": "501", "author": "Петров Пётр Петрович", "created": "2026-10-13T10:00:00.000+0300", "updated": "2026-10-13T10:00:00.000+0300", "body": "Первый комментарий e2e\nвторая строка"},
{"id": "502", "author": "Иванов Иван Иванович", "created": "2026-10-14T11:30:00.000+0300", "updated": "2026-10-14T12:00:00.000+0300", "body": "Схему согласовали с аналитиком."},
{"id": "503", "author": null, "created": "2026-10-15T09:15:00.000+0300", "updated": "2026-10-15T09:15:00.000+0300", "body": "[мост] служебный комментарий"}
]
}
},
"commands": [
{"type": "ensure_fields", "params": {"key": "AURORA-9001", "fields": {"duedate": "2026-10-20"}}, "status": "done"},
{"type": "ensure_status", "params": {"key": "AURORA-9001", "status": "В работе"}, "status": "failed", "error": "нет перехода в «В работе»"}
]
}
```
В `web/scripts/seed-hub.py`:
- в строку документации добавить предложение «Дополнения только для тестового хаба (описание, комментарии, команды) — из
seed-extras.json рядом со скриптом.»;
- после `snap = json.loads(...)` добавить:
```python
extras_path = Path(__file__).with_name("seed-extras.json")
extras = json.loads(extras_path.read_text(encoding="utf-8")) if extras_path.exists() else {}
extra_issues = extras.get("issues", {})
```
- строку `issues = [...]` заменить на:
```python
issues = [{k: v for k, v in i.items() if k not in ("fact_status", "work_kind", "neighbor")} | {"id": i["key"]}
| extra_issues.get(i["key"], {}) for i in snap["issues"]]
```
- перед строкой `(paths.home() / "tokens.json").write_text(...)` добавить:
```python
if extras.get("commands"): # завершённые команды по задачам — для раздела «Команды моста» карточки
with db.transaction(conn):
batch = conn.execute("INSERT INTO batch(title, author, created_at) VALUES ('Тестовые команды', 'seed', ?)",
(db.now(),)).lastrowid
for seq, c in enumerate(extras["commands"], 1):
conn.execute("INSERT INTO command(batch_id, seq, type, params, status, finished_at, error) "
"VALUES (?, ?, ?, ?, ?, ?, ?)",
(batch, seq, c["type"], json.dumps(c["params"], ensure_ascii=False), c["status"], db.now(),
c.get("error")))
```
Проверка: `JIRA_HUB_HOME=web/.e2e-hub bash web/scripts/test-hub.sh` в фоне, затем
`curl -s -H "Authorization: Bearer $(python3 -c "import json;print(json.load(open('web/.e2e-hub/tokens.json'))['client'])")" http://127.0.0.1:8791/issues/AURORA-9001 | python3 -c "import json,sys;d=json.load(sys.stdin);print(len(d['comments']), len(d['commands']), d['description'].count(chr(10)))"`
Expected: `3 2 11`. Остановить тестовый хаб.
- [ ] **Step 2: e2e списка задач**
Заменить `web/e2e/issues.spec.ts` целиком:
```ts
import { expect, test } from "@playwright/test";
import { login } from "./hub.ts";
test("фильтр → карточка окном → заметка сохраняется и переживает перезагрузку", async ({ page }) => {
await login(page, "/app/issues");
await page.getByLabel("Исполнитель").selectOption("Петров Пётр Петрович");
await expect(page).toHaveURL(/assignee=/);
await page.getByRole("link", { name: "AURORA-9002", exact: true }).click();
const d = page.getByRole("dialog");
await expect(d.getByRole("heading", { name: "API свода" })).toBeVisible();
await d.getByRole("button", { name: "Править" }).click();
await d.getByLabel("Осталось").fill("e2e: дописать тесты");
await d.getByRole("button", { name: "Сохранить" }).click();
await expect(d.getByText("e2e: дописать тесты")).toBeVisible();
await page.reload();
await expect(page.getByRole("dialog").getByText("e2e: дописать тесты")).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByRole("dialog")).toHaveCount(0);
await expect(page).toHaveURL(/\/app\/issues\?assignee=/);
await expect(page.getByLabel("Исполнитель")).toHaveValue("Петров Пётр Петрович");
});
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);
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();
await expect(page.getByRole("dialog")).toBeVisible();
expect(await page.evaluate(() => window.scrollY)).toBe(y);
await page.keyboard.press("Escape");
await expect(page.getByRole("dialog")).toHaveCount(0);
expect(await page.evaluate(() => window.scrollY)).toBe(y);
});
test("переход по связи и «назад»; ✕ и щелчок по фону; после закрытия «назад» не открывает карточку", async ({ page }) => {
await login(page); // доска
await page.getByRole("link", { name: "Задачи", exact: true }).click();
await page.getByRole("link", { name: "AURORA-9001", exact: true }).click();
const d = page.getByRole("dialog");
await d.getByRole("link", { name: "AURORA-9002", exact: true }).click();
await expect(page).toHaveURL(/\/app\/issues\/AURORA-9002$/);
await expect(d.getByRole("heading", { name: "API свода" })).toBeVisible();
await page.goBack();
await expect(d.getByRole("heading", { name: "Схема свода" })).toBeVisible();
await d.getByRole("link", { name: "AURORA-9002", exact: true }).click();
await d.getByRole("button", { name: "Закрыть" }).click();
await expect(page).toHaveURL(/\/app\/issues$/);
await expect(page.getByRole("dialog")).toHaveCount(0);
await page.getByRole("link", { name: "AURORA-9003", exact: true }).click();
await expect(page.getByRole("dialog")).toBeVisible();
await page.mouse.click(5, 300); // фон слева от окна
await expect(page.getByRole("dialog")).toHaveCount(0);
await page.goBack();
await expect(page).toHaveURL(/\/app\/board$/);
await expect(page.getByRole("dialog")).toHaveCount(0);
});
test("прямой адрес и перезагрузка: окно поверх списка, ✕ ведёт в список", async ({ page }) => {
await login(page, "/app/issues/AURORA-9001");
const d = page.getByRole("dialog");
await expect(d.getByRole("heading", { name: "Схема свода" })).toBeVisible();
await expect(page.getByTestId("page-issues")).toBeAttached();
await page.reload();
await expect(page.getByRole("dialog").getByRole("heading", { name: "Схема свода" })).toBeVisible();
await page.getByRole("dialog").getByRole("button", { name: "Закрыть" }).click();
await expect(page).toHaveURL(/\/app\/issues$/);
await expect(page.getByRole("dialog")).toHaveCount(0);
});
test("карточка: описание свёрнуто, комментарии новые сверху и скрываются с запоминанием, команды свёрнуты", async ({ page }) => {
await login(page, "/app/issues/AURORA-9001");
const d = page.getByRole("dialog");
await expect(d.getByRole("heading", { name: "Комментарии (3)" })).toBeVisible();
await expect(d.getByTestId("comments").getByRole("listitem").first()).toContainText("[мост] служебный комментарий");
await expect(d.getByRole("button", { name: "развернуть" })).toBeVisible();
await expect(d.getByText("Конец описания.")).toBeHidden();
await d.getByRole("button", { name: "развернуть" }).click();
await expect(d.getByText("Конец описания.")).toBeVisible();
await expect(d.getByRole("cell", { name: "ensure_fields" })).toBeHidden();
await d.getByText("Команды моста (2)").click();
await expect(d.getByRole("cell", { name: "ensure_fields" })).toBeVisible();
await d.getByRole("button", { name: "Скрыть" }).click();
await expect(d.getByText("Первый комментарий e2e")).toHaveCount(0);
await page.reload();
await expect(page.getByRole("dialog").getByRole("button", { name: "Показать" })).toBeVisible();
await expect(page.getByRole("dialog").getByText("Первый комментарий e2e")).toHaveCount(0);
});
test("несохранённая заметка: Esc спрашивает, «Остаться» оставляет окно, «Уйти» закрывает", async ({ page }) => {
await login(page, "/app/issues");
await page.getByRole("link", { name: "AURORA-9003", exact: true }).click();
const d = page.getByRole("dialog");
await d.getByRole("button", { name: "Править" }).click();
await d.getByLabel("Сделано").fill("черновик");
await page.keyboard.press("Escape");
await expect(d.getByText("Есть несохранённые правки заметки. Уйти без сохранения?")).toBeVisible();
await d.getByRole("button", { name: "Остаться" }).click();
await expect(d.getByLabel("Сделано")).toHaveValue("черновик");
await page.keyboard.press("Escape");
await d.getByRole("button", { name: "Уйти" }).click();
await expect(page.getByRole("dialog")).toHaveCount(0);
await expect(page).toHaveURL(/\/app\/issues$/);
});
test("узкий экран: окно на всю ширину, заметки выше связей, без горизонтальной прокрутки", async ({ page }) => {
await page.setViewportSize({ width: 600, height: 800 });
await login(page, "/app/issues/AURORA-9001");
const d = page.getByRole("dialog");
await expect(d.getByRole("heading", { name: "Схема свода" })).toBeVisible();
expect((await d.boundingBox())!.width).toBeGreaterThan(590);
const notesY = (await d.getByRole("heading", { name: "Служебные заметки" }).boundingBox())!.y;
const linksY = (await d.getByRole("heading", { name: "Связи", exact: true }).boundingBox())!.y;
expect(notesY).toBeLessThan(linksY);
expect(await d.evaluate((el) => el.scrollWidth <= el.clientWidth + 1)).toBe(true);
});
```
- [ ] **Step 3: e2e доски**
В `web/e2e/board.spec.ts` заменить тесты «ⓘ открывает карточку задачи в приложении» и «M-3: прокрутка доски после
возврата из карточки задачи» на:
```ts
test("ⓘ открывает карточку окном поверх доски", async ({ page }) => {
await page.locator('.board .card[data-key="AURORA-9001"]').getByRole("link", { name: "Открыть карточку AURORA-9001" }).click();
await expect(page).toHaveURL(/\/app\/issues\/AURORA-9001$/);
await expect(page.getByRole("dialog").getByRole("heading", { name: "Схема свода" })).toBeVisible();
await expect(page.getByTestId("page-board")).toBeAttached();
await expect(page.getByRole("link", { name: "Доска", exact: true })).toHaveClass(/active/);
});
test("M-3: прокрутка и данные доски после окна карточки и после другого раздела", async ({ page }) => {
await page.setViewportSize({ width: 1600, height: 600 });
await expect(page.locator(".board .card[data-key]").first()).toBeVisible();
const y = await page.evaluate(() => { window.scrollTo(0, document.documentElement.scrollHeight); return window.scrollY; });
expect(y).toBeGreaterThan(100);
// реальный щелчок по ⓘ карточки, видимой в окне после прокрутки (иначе Playwright сам сдвинет страницу)
const key = await page.evaluate(() => {
const vh = window.innerHeight;
const a = [...document.querySelectorAll<HTMLElement>(".board .card[data-key] a.open-card")]
.find((e) => { const r = e.getBoundingClientRect(); return r.top > 200 && r.bottom < vh - 20; });
return a?.closest<HTMLElement>(".card")?.dataset.key ?? null;
});
expect(key).not.toBeNull();
let boardLoads = 0;
page.on("request", (r) => { if (new URL(r.url()).pathname === "/board") boardLoads++; });
await page.locator(`.board .card[data-key="${key}"] a.open-card`).click();
await expect(page.getByRole("dialog")).toBeVisible();
await page.keyboard.press("Escape");
await expect(page).toHaveURL(/\/app\/board$/);
expect(await page.evaluate(() => window.scrollY)).toBeGreaterThan(y - 2);
expect(boardLoads).toBe(0); // доска не перезагружалась
await page.getByRole("link", { name: "Задачи", exact: true }).click();
await page.getByRole("link", { name: "Доска", exact: true }).click();
await expect.poll(() => page.evaluate(() => window.scrollY)).toBeGreaterThan(y - 2);
});
test("заметка из окна видна на доске под ним", async ({ page }) => {
await page.locator('.board .card[data-key="AURORA-9002"] a.open-card').click();
const d = page.getByRole("dialog");
await d.getByRole("button", { name: "Править" }).click();
await d.getByLabel("Вид работы").selectOption("тестирование");
await d.getByRole("button", { name: "Сохранить" }).click();
await expect(d.getByRole("button", { name: "Править" })).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.locator('.board .card[data-key="AURORA-9002"] .kind')).toHaveText("тестирование");
});
```
- [ ] **Step 4: Прогон e2e**
Run: `cd web && npm run e2e`
Expected: все тесты зелёные. Затем три прогона подряд: `cd web && for n in 1 2 3; do npm run e2e || break; done`
Expected: три зелёных прогона (устойчивость). Падение — чинить причину, не добавлять ожидания «на всякий случай».
- [ ] **Step 5: Документация**
В `README.md` в строке со списком адресов (`Адреса: … карточка \`/app/issues/{key}\` …`) заменить фрагмент
«карточка `/app/issues/{key}`» на «карточка `/app/issues/{key}` — окно поверх списка или доски (закрытие — ✕, Esc,
щелчок по фону; прямой адрес открывает окно поверх списка задач)». В том же разделе, после абзаца про «Задачи», добавить
абзац:
```markdown
Карточка задачи: в шапке — статус Jira, статус по факту, исполнитель, срок (просроченный — красным), спринт и эпик;
связи сгруппированы «Зависит от» / «Блокирует» / «Связана с» / «Прочие» с темой и статусом связанной задачи;
комментарии из Jira (агент v10+) новые сверху, «Скрыть» запоминается в браузере; заметки — режим чтения, «Править»
открывает форму. Комментарии появляются после первого обновления из Jira агентом v10.
```
В `ARCHITECTURE.md`:
- строку таблицы `| \`mirror\` | зеркало задач и связей, \`apply_state\`, заметки, фильтры, снимок доски \`board\` | \`db\` |`
заменить на `| \`mirror\` | зеркало задач, связей и комментариев, \`apply_state\` (с проверкой \`check_comments\`), заметки, фильтры, снимок доски \`board\` | \`db\` |`;
- в абзаце про `web/` фрагмент «и «Задачи» (`/app/issues`, `/app/issues/{key}`).» заменить на «и «Задачи»
(`/app/issues`); карточка `/app/issues/{key}` — окно поверх страницы, с которой её открыли: `shell/Layout.tsx` рисует
страницу по адресу из `location.state.background` (`useRoutes` с явным адресом) и `issues/IssueModal.tsx` поверх неё.».
- [ ] **Step 6: Полная проверка**
Run: `make check`
Expected: pytest и vitest зелёные, lint чистый.
- [ ] **Step 7: Commit**
```bash
git add web/scripts/seed-extras.json web/scripts/seed-hub.py web/e2e README.md ARCHITECTURE.md
git commit -m "test(web): e2e окна карточки, тестовый хаб с комментариями и командами; README (#37)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
## После плана
- Финальное ревью ветки (opus); ручной проход сценариев в браузере на тестовом хабе :8791 со скриншотами (делает
сессия, не контроллер).
- PR в aep/jira-hub со ссылкой на #37 и на PR агента; merge — по команде владельца, после merge и публикации агента v10.