5269 lines
283 KiB
Markdown
5269 lines
283 KiB
Markdown
# web-app — веб-приложение хаба на React: план реализации
|
||
|
||
> **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:** хаб отдаёт по `/app/` веб-приложение на React (вход по токену, «Доска», «Задачи»); доска связей перенесена из
|
||
`links.html` без потери поведения и раскладок, M-1…M-7 jira-hub#20 закрыты; `/` и `/links` перенаправляют на `/app/board`.
|
||
|
||
**Architecture:** npm-проект `web/` (React 19 + TS + Vite) собирается в `src/jira_hub/web/app/` (не в git); хаб раздаёт
|
||
сборку без токена (`/app/assets/*` — файлы, прочие `/app…` — `index.html`, 503 без сборки). Данные — только через
|
||
существующий HTTP API с клиентским токеном. Логика доски — чистые TS-модули (`web/src/board/{model,analysis,layout}`,
|
||
`storage.ts`), проверенные golden-эталонами, которые генерирует сама старая страница (ревизия 3931a88) в `node:vm`;
|
||
отрисовка — React-компоненты, данные и опрос пакета — TanStack Query.
|
||
|
||
**Tech Stack:** Python 3.13, uv, pytest, stdlib `http.server` (хаб); Node 24, npm, React 19, TypeScript (strict),
|
||
Vite, React Router, TanStack Query 5, Vitest + Testing Library (jsdom), ESLint (typescript-eslint), Playwright (e2e).
|
||
|
||
**Spec:** `openspec/changes/web-app/` (proposal.md, design.md, specs/web-app/spec.md, specs/hub-api/spec.md).
|
||
|
||
## Global Constraints
|
||
|
||
- Рабочий каталог: `/Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/web-app` (ветка `feat/21-web-app`). Все команды —
|
||
с этим cwd (фронт — `web/` внутри него). Основной checkout `jira-hub/` (main) не правится; живой хаб `:8790` не трогается.
|
||
- codegraph: в `codegraph_explore` передавать `projectPath` = `/Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/web-app`.
|
||
- Комментарии, docstring, тексты интерфейса, сообщения ошибок и коммитов — по-русски; идентификаторы — английские.
|
||
- Источник поведения доски — `src/jira_hub/web/links.html` **ревизии 3931a88** (`git show 3931a88:src/jira_hub/web/links.html`);
|
||
номера строк в плане — по этой ревизии. Перенос алгоритмов — построчный; переименование и упрощение допустимы только
|
||
если golden-тесты остаются зелёными.
|
||
- Зависимости npm ставятся только `npm install --save-exact --before=2026-09-30 …` (версии старше двух недель, без `^`);
|
||
`package-lock.json` в git; зависимостей времени выполнения ровно четыре: `react`, `react-dom`, `react-router`,
|
||
`@tanstack/react-query`. У Python-пакета зависимостей не прибавляется.
|
||
- Ключи `localStorage` — ровно как у `links.html`: токен `jira-hub-token`; общие настройки `graph-<имя>`; настройки
|
||
доски `graph-<имя>:board` (`pos`, `posdate`, `lanes`, `fold`, `sprint`, `who`, `colw`); миграция `migrateStore` и
|
||
старого `graph-mode` сохраняется.
|
||
- Приложение — под `/app/` (`base: "/app/"`, `basename: "/app"`); маршруты `/app/board`, `/app/issues`, `/app/issues/:key`.
|
||
- «Обновить из Jira» — ровно `POST /refresh {"full": true, "author": "viewer"}`; опрос `GET /batches/{id}` каждые 3000 мс;
|
||
опрос прекращается на 404 и после 5 ошибок «хаб не отвечает» подряд.
|
||
- Заметки — `PUT /issues/{key}/note` только с изменёнными полями и `updated_by: "web"`; очищенное поле — `null`.
|
||
- Словари `kind_labels`, `link_types`, `status_labels` — только из `GET /settings`; в коде фронта их копий нет
|
||
(`EPICS`, `KIND`, `PRIO`, `JIRA_URL` — константы оформления, остаются в коде).
|
||
- Фикстуры — синтетические (R-4): ключи `AURORA-9xxx`, вымышленные ФИО; данных живого зеркала в git нет.
|
||
- Тесты: `uv run pytest -q` (до начала: 206 passed) и `cd web && npm run lint && npm test`; зелёным считается только
|
||
весь набор; итог — `make check`.
|
||
- Каждая задача заканчивается коммитом с заголовком на русском, суффиксом `(#21)` и строкой
|
||
`Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.
|
||
- Решения исполнителя без обсуждения — в `openspec/changes/web-app/design.md`, раздел
|
||
`## Decisions made without discussion`, со следующим свободным маркером (занято R-1…R-11).
|
||
- Клиентский токен **живого** хаба не печатать и не вводить в браузер: проверки — на тестовом хабе `:8791`.
|
||
- Модель исполнителя указана у каждой задачи (SDD Model Selection: готовый код — haiku, перенос и интеграция — sonnet).
|
||
|
||
## Review Focus
|
||
|
||
1. Токен вставлен с пробелом, переводом строки или кириллицей: `fetch` с таким заголовком бросает `TypeError`, и
|
||
приложение не должно сообщать «хаб не отвечает» — экран входа говорит «в токене недопустимые символы» (как
|
||
`links.html`, строка 1080). Тест — Task 3, `LoginScreen.test.tsx`.
|
||
2. Путь к файлу сборки с обходом каталога (`/app/assets/../../api.py`, `%2e%2e`, `%2F`) — 404, а не содержимое файла;
|
||
запросы шлёт `http.client` без нормализации пути. Тест — Task 1.
|
||
3. Поздний ответ на один запрос перебивает более новый: открыта карточка AURORA-9001, затем быстро AURORA-9002 — на экране
|
||
должна быть 9002; ответ «Сохранить» заметки после ухода в другую карточку не пишет её в форму новой. Ключи запросов
|
||
TanStack Query включают `key` задачи. Тесты — Task 14.
|
||
4. Пустые словари в настройках (`kind_labels`/`link_types` = `{}` на чистой базе): доска показывает «в настройках хаба нет
|
||
…», а не пустую доску без связей. Тест — Task 11, `BoardPage.test.tsx`.
|
||
5. `localStorage` недоступен (приватный режим, `SecurityError` на любом обращении): вход работает с токеном в памяти,
|
||
доска строится, раскладка просто не сохраняется — без исключений. Тесты — Task 3 (`token.test.ts`) и Task 9
|
||
(`storage.test.ts`).
|
||
|
||
---
|
||
|
||
## Структура файлов
|
||
|
||
```
|
||
Makefile make web, make check
|
||
.gitignore + src/jira_hub/web/app/, web/test-results/, web/playwright-report/
|
||
src/jira_hub/api.py раздача /app…, Raw.headers, редирект / и /links (Task 16)
|
||
tests/test_web_app.py раздача приложения
|
||
tests/test_mirror.py + M-7
|
||
web/
|
||
package.json, package-lock.json, tsconfig.json, vite.config.ts, eslint.config.js, index.html, playwright.config.ts
|
||
scripts/golden-from-links.ts генератор эталонов из links.html@3931a88 (node:vm)
|
||
scripts/test-hub.sh тестовый хаб :8791 для разработки и e2e
|
||
e2e/*.spec.ts Playwright
|
||
src/main.tsx, src/App.tsx вход, провайдеры, маршрутизатор
|
||
src/test/setup.ts, src/test/canon.ts настройка Vitest, нормализация для golden
|
||
src/api/types.ts, client.ts, queries.ts типы API, клиент, хуки запросов
|
||
src/auth/token.ts, AuthContext.tsx, LoginScreen.tsx
|
||
src/shell/Layout.tsx, ErrorBoundary.tsx, NotFound.tsx
|
||
src/styles/theme.css, base.css, controls.css
|
||
src/board/constants.ts, types.ts, dicts.ts
|
||
src/board/model/{util,csv,fromHub,fromCsv,fromJson}.ts
|
||
src/board/analysis/{visible,critical,problems,facets}.ts
|
||
src/board/layout/{layout,edges,align}.ts
|
||
src/board/storage.ts, exportJson.ts
|
||
src/board/useBoardSource.ts, useBoardPrefs.ts
|
||
src/board/components/*.tsx, board.css
|
||
src/board/__fixtures__/* синтетические фикстуры и эталоны *.expected.json
|
||
src/issues/IssuesPage.tsx, IssuePage.tsx, NoteForm.tsx, issues.css
|
||
```
|
||
|
||
Общие типы (определяются в задачах, используются дальше — имена не менять):
|
||
|
||
- `web/src/api/types.ts` (Task 3): `BoardIssue`, `BoardLink`, `SyncRun`, `BoardSnapshot`, `HubSettings`, `IssueRow`,
|
||
`IssueDetail`, `Note`, `NoteFields`, `IssueCommand`, `Batch`, `BatchStatus`, `AgentView`, `CreatedBatch`.
|
||
- `web/src/api/client.ts` (Task 3): `AuthError`, `HubDownError`, `ApiError`, `api.get/post/put`.
|
||
- `web/src/board/types.ts` (Task 6): `BoardNode`, `Link`, `BoardModel`, `Dicts`, `Filters`, `VisNode`, `VisLink`,
|
||
`Visible`, `Problems`, `JsonImport`, `SavedBox`.
|
||
- `web/src/board/layout/layout.ts` (Task 8): `Rect`, `RelBox`, `Layout`, `LaneGeom`, `Placed`, `DateCol`.
|
||
|
||
---
|
||
### Task 1: хаб раздаёт веб-приложение по `/app…` (и тест M-7)
|
||
|
||
**Модель:** haiku (код готов).
|
||
|
||
**Files:**
|
||
- Modify: `src/jira_hub/api.py` (докстринг модуля, константы после `PAGE`, `Raw`, `Hub.__init__`, новый обработчик
|
||
`h_app` после `h_page`, `ROUTES`, `Handler._send`)
|
||
- Create: `tests/test_web_app.py`
|
||
- Modify: `tests/test_mirror.py` (новый тест после `test_board_last_sync_is_last_successful_full_refresh`)
|
||
|
||
**Interfaces:**
|
||
- Produces: `api.WEB_APP: Path` — каталог сборки по умолчанию; `api.Hub(db_path, backups_dir=None, web_dir=None)` —
|
||
`hub.web_dir: Path`; `api.Raw(content_type, data, headers=None)` — `headers=None` означает `Cache-Control: no-cache`;
|
||
маршрут `GET ^/app(?:/(.*))?$` без токена. `GET /` и `GET /links` пока отдают `links.html` (переключение — Task 16).
|
||
|
||
- [ ] **Step 1: Написать падающие тесты раздачи**
|
||
|
||
`tests/test_web_app.py`:
|
||
|
||
```python
|
||
"""Раздача веб-приложения: GET /app… без токена (specs/hub-api «Раздача веб-приложения»)."""
|
||
import http.client
|
||
import threading
|
||
from urllib.parse import urlparse
|
||
|
||
import pytest
|
||
|
||
from jira_hub import api
|
||
|
||
|
||
def _get(base, path):
|
||
"""Сырой GET: http.client не нормализует путь (../ и %2e%2e доходят до хаба как есть)."""
|
||
u = urlparse(base)
|
||
c = http.client.HTTPConnection(u.hostname, u.port, timeout=10)
|
||
try:
|
||
c.request("GET", path)
|
||
r = c.getresponse()
|
||
return r.status, r, r.read()
|
||
finally:
|
||
c.close()
|
||
|
||
|
||
def _serve(tmp_path, web_dir):
|
||
h = api.Hub(tmp_path / "hub.sqlite", backups_dir=tmp_path / "backups", web_dir=web_dir)
|
||
srv = api.make_server(h, "127.0.0.1", 0)
|
||
th = threading.Thread(target=srv.serve_forever, daemon=True)
|
||
th.start()
|
||
return srv, th, f"http://127.0.0.1:{srv.server_port}"
|
||
|
||
|
||
@pytest.fixture
|
||
def app_hub(tmp_path):
|
||
web = tmp_path / "app"
|
||
(web / "assets").mkdir(parents=True)
|
||
(web / "index.html").write_text("<!doctype html><div id=root></div>", encoding="utf-8")
|
||
(web / "assets" / "index-abc123.js").write_text("console.log('ok')", encoding="utf-8")
|
||
(web / "assets" / "index-abc123.css").write_text("body{}", encoding="utf-8")
|
||
(web / "assets" / "font.bin").write_bytes(b"\x00\x01")
|
||
(tmp_path / "secret.txt").write_text("секрет", encoding="utf-8")
|
||
srv, th, base = _serve(tmp_path, web)
|
||
yield base
|
||
srv.shutdown()
|
||
srv.server_close()
|
||
th.join(5)
|
||
|
||
|
||
def test_asset_served_with_type_and_immutable_cache(app_hub):
|
||
status, r, body = _get(app_hub, "/app/assets/index-abc123.js")
|
||
assert status == 200
|
||
assert r.getheader("Content-Type") == "text/javascript; charset=utf-8"
|
||
assert r.getheader("Cache-Control") == "public, max-age=31536000, immutable"
|
||
assert body == b"console.log('ok')"
|
||
status, r, _ = _get(app_hub, "/app/assets/index-abc123.css")
|
||
assert r.getheader("Content-Type") == "text/css; charset=utf-8"
|
||
status, r, _ = _get(app_hub, "/app/assets/font.bin")
|
||
assert status == 200 and r.getheader("Content-Type") == "application/octet-stream"
|
||
|
||
|
||
@pytest.mark.parametrize("path", ["/app", "/app/", "/app/board", "/app/issues/AURORA-1620", "/app/issues?sprint=x"])
|
||
def test_app_paths_get_index_without_token(app_hub, path):
|
||
status, r, body = _get(app_hub, path)
|
||
assert status == 200
|
||
assert r.getheader("Content-Type") == "text/html; charset=utf-8"
|
||
assert r.getheader("Cache-Control") == "no-cache"
|
||
assert body == b"<!doctype html><div id=root></div>"
|
||
|
||
|
||
@pytest.mark.parametrize("path", ["/app/assets/../../secret.txt", "/app/assets/%2e%2e/%2e%2e/secret.txt",
|
||
"/app/assets/..%2F..%2Fsecret.txt", "/app/assets/missing.js", "/app/assets/"])
|
||
def test_asset_outside_or_missing_is_404(app_hub, path):
|
||
status, _, body = _get(app_hub, path)
|
||
assert status == 404
|
||
assert "секрет".encode("utf-8") not in body
|
||
|
||
|
||
def test_not_built_is_503_with_hint(tmp_path):
|
||
srv, th, base = _serve(tmp_path, tmp_path / "no-build")
|
||
try:
|
||
for path in ("/app/board", "/app/assets/index-abc123.js"):
|
||
status, r, body = _get(base, path)
|
||
assert status == 503
|
||
assert r.getheader("Content-Type") == "text/html; charset=utf-8"
|
||
assert "make web".encode("utf-8") in body
|
||
finally:
|
||
srv.shutdown()
|
||
srv.server_close()
|
||
th.join(5)
|
||
|
||
|
||
def test_other_paths_are_not_app(app_hub):
|
||
status, _, _ = _get(app_hub, "/application")
|
||
assert status == 404 # не префикс /app/, обычный 404 API
|
||
status, _, _ = _get(app_hub, "/board")
|
||
assert status == 401 # API по-прежнему под токеном
|
||
|
||
|
||
def test_default_web_dir_is_package_build(tmp_path):
|
||
h = api.Hub(tmp_path / "hub.sqlite", backups_dir=tmp_path / "backups")
|
||
assert h.web_dir == api.WEB_APP
|
||
assert api.WEB_APP.parts[-3:] == ("jira_hub", "web", "app")
|
||
```
|
||
|
||
- [ ] **Step 2: Тест M-7 — `mirror.board` внутри открытой транзакции**
|
||
|
||
В `tests/test_mirror.py` после `test_board_last_sync_is_last_successful_full_refresh`:
|
||
|
||
```python
|
||
def test_board_inside_open_transaction_keeps_it_open():
|
||
"""M-7 #20: вызов внутри уже открытой транзакции не открывает и не закрывает свою."""
|
||
conn = db.connect(":memory:")
|
||
mirror.apply_state(conn, [issue("AURORA-1620")], [])
|
||
with db.transaction(conn):
|
||
assert conn.in_transaction
|
||
snap = mirror.board(conn, BASE)
|
||
assert conn.in_transaction
|
||
assert not conn.in_transaction
|
||
assert [i["key"] for i in snap["issues"]] == ["AURORA-1620"]
|
||
```
|
||
|
||
- [ ] **Step 3: Запустить — новые тесты раздачи падают, M-7 проходит**
|
||
|
||
Run: `uv run pytest tests/test_web_app.py tests/test_mirror.py -q`
|
||
Expected: FAIL в `test_web_app.py` (`TypeError: Hub.__init__() got an unexpected keyword argument 'web_dir'`);
|
||
`test_board_inside_open_transaction_keeps_it_open` — PASS (код уже верный, тест закрывает пробел покрытия).
|
||
|
||
- [ ] **Step 4: Реализация в `src/jira_hub/api.py`**
|
||
|
||
Докстринг модуля — третья строка заменяется:
|
||
|
||
```python
|
||
"""HTTP API хаба (stdlib http.server, поток на запрос, соединение SQLite на запрос).
|
||
Маршруты /agent/* — агентский токен, остальные — клиентский (specs/hub-api/spec.md).
|
||
Без токена — статика без данных: страница доски (GET /, GET /links) и веб-приложение (GET /app…)."""
|
||
```
|
||
|
||
Импорт: `from urllib.parse import parse_qs, unquote, urlparse`.
|
||
|
||
После строки `PAGE = …`:
|
||
|
||
```python
|
||
WEB_APP = Path(__file__).parent / "web" / "app" # сборка веб-приложения (make web, не в git); читается на каждый запрос
|
||
ASSET_TYPES = {".js": "text/javascript; charset=utf-8", ".css": "text/css; charset=utf-8", ".svg": "image/svg+xml",
|
||
".png": "image/png", ".ico": "image/x-icon", ".woff2": "font/woff2", ".map": "application/json"}
|
||
ASSET_CACHE = {"Cache-Control": "public, max-age=31536000, immutable"} # имена файлов сборки содержат хэш (R-3)
|
||
NOT_BUILT = ("<!doctype html><meta charset=\"utf-8\"><title>Хаб Jira</title><p>Веб-приложение не собрано: выполните "
|
||
"<code>make web</code> в каталоге jira-hub и перезапустите хаб.</p>").encode("utf-8")
|
||
```
|
||
|
||
`Raw`:
|
||
|
||
```python
|
||
class Raw(NamedTuple):
|
||
"""Не-JSON ответ обработчика (страница доски, веб-приложение). headers=None — Cache-Control: no-cache."""
|
||
content_type: str
|
||
data: bytes
|
||
headers: dict | None = None
|
||
```
|
||
|
||
`Hub.__init__` — сигнатура и первая строка тела:
|
||
|
||
```python
|
||
def __init__(self, db_path, backups_dir=None, web_dir=None):
|
||
self.db_path = Path(db_path)
|
||
self.web_dir = Path(web_dir) if web_dir else WEB_APP
|
||
```
|
||
|
||
После `h_page`:
|
||
|
||
```python
|
||
def h_app(hub, conn, m, q, body):
|
||
"""Веб-приложение: файлы assets/ сборки как есть, любой другой путь /app… — index.html (маршруты на клиенте).
|
||
Путь файла после разрешения обязан лежать внутри assets/, иначе 404; без сборки — 503 с подсказкой."""
|
||
index = hub.web_dir / "index.html"
|
||
if not index.is_file():
|
||
return 503, Raw("text/html; charset=utf-8", NOT_BUILT)
|
||
rest = unquote(m.group(1) or "")
|
||
if rest.startswith("assets/"):
|
||
assets = (hub.web_dir / "assets").resolve()
|
||
f = (hub.web_dir / rest).resolve()
|
||
if not f.is_relative_to(assets) or not f.is_file():
|
||
raise ApiError(404, "not_found", "/app/" + rest)
|
||
return 200, Raw(ASSET_TYPES.get(f.suffix, "application/octet-stream"), f.read_bytes(), ASSET_CACHE)
|
||
return 200, Raw("text/html; charset=utf-8", index.read_bytes())
|
||
```
|
||
|
||
`ROUTES` — после строки `("GET", r"^/(links)?$", h_page, None),`:
|
||
|
||
```python
|
||
("GET", r"^/app(?:/(.*))?$", h_app, None),
|
||
```
|
||
|
||
`Handler._send` — первая ветка:
|
||
|
||
```python
|
||
if isinstance(payload, Raw):
|
||
ctype, data = payload.content_type, payload.data
|
||
extra = payload.headers if payload.headers is not None else {"Cache-Control": "no-cache"}
|
||
```
|
||
|
||
- [ ] **Step 5: Запустить весь набор**
|
||
|
||
Run: `uv run pytest -q`
|
||
Expected: PASS, 206 + 15 новых (`test_web_app.py` — 14 с параметризацией, `test_mirror.py` — 1) = 221 passed.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add src/jira_hub/api.py tests/test_web_app.py tests/test_mirror.py
|
||
git commit -m "feat(api): раздача веб-приложения по /app… без токена, 503 без сборки; тест M-7 (#21)
|
||
|
||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: каркас `web/` — Vite, TypeScript, ESLint, Vitest, Makefile
|
||
|
||
**Модель:** sonnet (установка зависимостей и сверка конфигов с установленными версиями).
|
||
|
||
**Files:**
|
||
- Create: `web/package.json`, `web/package-lock.json` (npm), `web/tsconfig.json`, `web/vite.config.ts`,
|
||
`web/eslint.config.js`, `web/index.html`, `web/src/main.tsx`, `web/src/App.tsx`, `web/src/App.test.tsx`,
|
||
`web/src/test/setup.ts`, `web/src/test/canon.ts`, `web/src/test/canon.test.ts`, `web/src/vite-env.d.ts`, `Makefile`
|
||
- Modify: `.gitignore`
|
||
|
||
**Interfaces:**
|
||
- Produces: `npm run dev|build|lint|test|e2e|golden`; `make web`, `make check`; `canon(v: unknown): unknown` в
|
||
`web/src/test/canon.ts` (нормализация для сравнения с эталоном; работает с объектами другого realm — `node:vm`).
|
||
|
||
- [ ] **Step 1: `package.json` и зависимости**
|
||
|
||
```bash
|
||
mkdir -p web/src/test web/scripts web/e2e
|
||
cd web
|
||
cat > package.json <<'EOF'
|
||
{
|
||
"name": "jira-hub-web",
|
||
"private": true,
|
||
"type": "module",
|
||
"scripts": {
|
||
"dev": "vite",
|
||
"build": "tsc --noEmit -p . && vite build",
|
||
"lint": "tsc --noEmit -p . && eslint .",
|
||
"test": "vitest run",
|
||
"e2e": "playwright test",
|
||
"golden": "node scripts/golden-from-links.ts"
|
||
}
|
||
}
|
||
EOF
|
||
npm install --save-exact --before=2026-09-30 react react-dom react-router @tanstack/react-query
|
||
npm install --save-exact --save-dev --before=2026-09-30 typescript vite @vitejs/plugin-react vitest jsdom \
|
||
@testing-library/react @testing-library/dom @testing-library/user-event @testing-library/jest-dom \
|
||
@types/react @types/react-dom @types/node eslint @eslint/js typescript-eslint eslint-plugin-react-hooks globals \
|
||
@playwright/test
|
||
cd ..
|
||
```
|
||
|
||
Проверить: в `web/package.json` у всех зависимостей версии без `^`/`~`; в `dependencies` ровно четыре пакета. Если
|
||
`npm install` сообщает конфликт peer-зависимостей — взять предыдущую минорную версию проблемного dev-пакета с тем же
|
||
`--before` (не `--force`, не `--legacy-peer-deps`) и записать выбор в design.md (следующий маркер R-n).
|
||
|
||
- [ ] **Step 2: Конфиги**
|
||
|
||
`web/tsconfig.json`:
|
||
|
||
```json
|
||
{
|
||
"compilerOptions": {
|
||
"target": "ES2022",
|
||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||
"module": "ESNext",
|
||
"moduleResolution": "bundler",
|
||
"jsx": "react-jsx",
|
||
"strict": true,
|
||
"noUnusedLocals": true,
|
||
"noUnusedParameters": true,
|
||
"noFallthroughCasesInSwitch": true,
|
||
"resolveJsonModule": true,
|
||
"isolatedModules": true,
|
||
"erasableSyntaxOnly": true,
|
||
"allowImportingTsExtensions": true,
|
||
"noEmit": true,
|
||
"skipLibCheck": true,
|
||
"types": ["node", "vite/client", "@testing-library/jest-dom"]
|
||
},
|
||
"include": ["src", "scripts", "e2e", "vite.config.ts", "playwright.config.ts"]
|
||
}
|
||
```
|
||
|
||
`erasableSyntaxOnly` нужен, чтобы `scripts/*.ts` запускались Node 24 без сборки (type stripping): без `enum`,
|
||
`namespace`, параметров-свойств конструктора.
|
||
|
||
`web/vite.config.ts`:
|
||
|
||
```ts
|
||
import react from "@vitejs/plugin-react";
|
||
import { defineConfig } from "vitest/config";
|
||
|
||
// сервер разработки: приложение под /app/, маршруты API — прокси на хаб (по умолчанию тестовый :8791, R-5)
|
||
const hub = process.env.JIRA_HUB_URL ?? "http://127.0.0.1:8791";
|
||
const API = ["/board", "/issues", "/settings", "/agent", "/refresh", "/batches", "/commands", "/sync-runs"];
|
||
|
||
export default defineConfig({
|
||
base: "/app/",
|
||
plugins: [react()],
|
||
build: { outDir: "../src/jira_hub/web/app", emptyOutDir: true },
|
||
server: { host: "127.0.0.1", port: 5173, proxy: Object.fromEntries(API.map((p) => [p, { target: hub }])) },
|
||
test: {
|
||
environment: "jsdom",
|
||
setupFiles: ["./src/test/setup.ts"],
|
||
include: ["src/**/*.test.{ts,tsx}"],
|
||
restoreMocks: true,
|
||
unstubGlobals: true,
|
||
},
|
||
});
|
||
```
|
||
|
||
`web/eslint.config.js`:
|
||
|
||
```js
|
||
import js from "@eslint/js";
|
||
import reactHooks from "eslint-plugin-react-hooks";
|
||
import globals from "globals";
|
||
import tseslint from "typescript-eslint";
|
||
|
||
export default tseslint.config(
|
||
{ ignores: ["node_modules", "playwright-report", "test-results"] },
|
||
js.configs.recommended,
|
||
...tseslint.configs.recommended,
|
||
{
|
||
files: ["**/*.{ts,tsx,js}"],
|
||
languageOptions: { globals: { ...globals.browser, ...globals.node } },
|
||
plugins: { "react-hooks": reactHooks },
|
||
rules: { "react-hooks/rules-of-hooks": "error", "react-hooks/exhaustive-deps": "error" },
|
||
},
|
||
);
|
||
```
|
||
|
||
`web/index.html`:
|
||
|
||
```html
|
||
<!doctype html>
|
||
<html lang="ru">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>Хаб Jira</title>
|
||
<link rel="icon" href="data:,">
|
||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@500&display=swap">
|
||
</head>
|
||
<body>
|
||
<div id="root"></div>
|
||
<script type="module" src="/src/main.tsx"></script>
|
||
</body>
|
||
</html>
|
||
```
|
||
|
||
`web/src/vite-env.d.ts`: `/// <reference types="vite/client" />`
|
||
|
||
`web/src/test/setup.ts`:
|
||
|
||
```ts
|
||
import "@testing-library/jest-dom/vitest";
|
||
import { cleanup } from "@testing-library/react";
|
||
import { afterEach } from "vitest";
|
||
|
||
afterEach(() => {
|
||
cleanup();
|
||
localStorage.clear();
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 3: `canon` — тест и реализация**
|
||
|
||
`web/src/test/canon.test.ts`:
|
||
|
||
```ts
|
||
import vm from "node:vm";
|
||
import { describe, expect, it } from "vitest";
|
||
import { canon } from "./canon.ts";
|
||
|
||
describe("canon", () => {
|
||
it("сортирует ключи объектов и выбрасывает undefined", () => {
|
||
expect(JSON.stringify(canon({ b: 1, a: { d: undefined, c: [2, 1] } }))).toBe('{"a":{"c":[2,1]},"b":1}');
|
||
});
|
||
it("Set — отсортированный массив, в том числе из другого realm", () => {
|
||
const foreign = vm.runInNewContext('new Set(["b", "a"])');
|
||
expect(canon(foreign)).toEqual(["a", "b"]);
|
||
expect(canon(new Set([{ k: 2 }, { k: 1 }]))).toEqual([{ k: 1 }, { k: 2 }]);
|
||
});
|
||
});
|
||
```
|
||
|
||
`web/src/test/canon.ts`:
|
||
|
||
```ts
|
||
// нормализация значений для сравнения с golden-эталоном: ключи объектов по алфавиту, Set — отсортированный массив,
|
||
// undefined-поля выбрасываются (как в JSON). Set распознаётся по тегу: объекты из node:vm — из другого realm
|
||
const isSet = (v: unknown): v is Set<unknown> => Object.prototype.toString.call(v) === "[object Set]";
|
||
|
||
export function canon(v: unknown): unknown {
|
||
if (isSet(v)) return [...v].map(canon).sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)));
|
||
if (Array.isArray(v)) return v.map(canon);
|
||
if (v && typeof v === "object") {
|
||
const o = v as Record<string, unknown>;
|
||
return Object.fromEntries(Object.keys(o).sort().filter((k) => o[k] !== undefined).map((k) => [k, canon(o[k])]));
|
||
}
|
||
return v;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Заглушка приложения и её тест**
|
||
|
||
`web/src/App.tsx`:
|
||
|
||
```tsx
|
||
export function App() {
|
||
return <h1>Хаб Jira</h1>;
|
||
}
|
||
```
|
||
|
||
`web/src/main.tsx`:
|
||
|
||
```tsx
|
||
import { StrictMode } from "react";
|
||
import { createRoot } from "react-dom/client";
|
||
import { App } from "./App.tsx";
|
||
|
||
createRoot(document.getElementById("root")!).render(
|
||
<StrictMode>
|
||
<App />
|
||
</StrictMode>,
|
||
);
|
||
```
|
||
|
||
`web/src/App.test.tsx`:
|
||
|
||
```tsx
|
||
import { render, screen } from "@testing-library/react";
|
||
import { expect, it } from "vitest";
|
||
import { App } from "./App.tsx";
|
||
|
||
it("показывает заголовок", () => {
|
||
render(<App />);
|
||
expect(screen.getByRole("heading", { name: "Хаб Jira" })).toBeInTheDocument();
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 5: `Makefile` и `.gitignore`**
|
||
|
||
`Makefile` (отступы — табуляция):
|
||
|
||
```make
|
||
.PHONY: web check
|
||
|
||
# сборка веб-приложения в src/jira_hub/web/app/ (каталог не в git; хаб читает его на каждый запрос)
|
||
web:
|
||
cd web && npm ci && npm run build
|
||
|
||
# весь набор проверок: хаб и фронт; зелёным считается только всё вместе
|
||
check:
|
||
uv run pytest -q
|
||
cd web && npm run lint && npm test
|
||
```
|
||
|
||
В `.gitignore` дописать:
|
||
|
||
```
|
||
src/jira_hub/web/app/
|
||
web/test-results/
|
||
web/playwright-report/
|
||
```
|
||
|
||
- [ ] **Step 6: Проверки**
|
||
|
||
Run: `cd web && npm run lint && npm test && cd .. && make web && ls src/jira_hub/web/app src/jira_hub/web/app/assets && git status --porcelain`
|
||
Expected: lint без ошибок; `2 passed` файла тестов (3 теста); в сборке `index.html` и `assets/index-*.js`;
|
||
`git status` показывает только новые исходники (`web/…`, `Makefile`, `.gitignore`), файлов сборки и `node_modules` в нём нет.
|
||
|
||
Run: `make check`
|
||
Expected: pytest 221 passed, фронт зелёный.
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add Makefile .gitignore web/package.json web/package-lock.json web/tsconfig.json web/vite.config.ts \
|
||
web/eslint.config.js web/index.html web/src
|
||
git commit -m "chore(web): каркас веб-приложения — Vite, TypeScript, ESLint, Vitest, make web/check (#21)
|
||
|
||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
### Task 3: API — типы, клиент, токен, экран входа, хуки запросов
|
||
|
||
**Модель:** haiku (код готов).
|
||
|
||
**Files:**
|
||
- Create: `web/src/api/types.ts`, `web/src/api/client.ts`, `web/src/api/queryClient.ts`, `web/src/api/queries.ts`,
|
||
`web/src/auth/token.ts`, `web/src/auth/useAuth.ts`, `web/src/auth/LoginScreen.tsx`, `web/src/auth/AuthGate.tsx`,
|
||
`web/src/test/fetchMock.ts`, `web/src/test/render.tsx`
|
||
- Test: `web/src/api/client.test.ts`, `web/src/auth/token.test.ts`, `web/src/auth/LoginScreen.test.tsx`
|
||
|
||
**Interfaces:**
|
||
- Produces:
|
||
- `token.ts`: `TOKEN_KEY = "jira-hub-token"`, `TOKEN_RE`, `type LoginReason = "none" | "bad"`,
|
||
`interface AuthSnapshot {token: string; reason: LoginReason}`, `getAuth(): AuthSnapshot`, `getToken(): string`,
|
||
`setToken(t: string): void`, `clearToken(reason?: LoginReason): void`, `subscribe(fn): () => void`.
|
||
- `useAuth.ts`: `useAuth(): AuthSnapshot`.
|
||
- `client.ts`: классы `AuthError`, `HubDownError`, `ApiError {status, error, detail}`;
|
||
`api.get<T>(path, params?)`, `api.post<T>(path, body?)`, `api.put<T>(path, body)`, `checkToken(token): Promise<HubSettings>`.
|
||
- `queryClient.ts`: `makeQueryClient(): QueryClient` (401 в любом запросе → `clearToken("bad")`).
|
||
- `queries.ts`: `qk`, `useSettings()`, `useBoard()`, `useIssue(key)`, `useIssues(filters)`, `useSaveNote(key)`,
|
||
`type IssueFilters`, `ISSUE_FILTER_NAMES`, `ISSUE_PAGE = 50`.
|
||
- `AuthGate` — показывает `LoginScreen`, пока нет токена; иначе детей.
|
||
- тестовые помощники: `json(status, body): Response`, `mockFetch(handler)`, `renderWithProviders(ui, {route?, path?})`.
|
||
|
||
- [ ] **Step 1: Типы API** — `web/src/api/types.ts`:
|
||
|
||
```ts
|
||
// ответы HTTP API хаба (openspec/specs/hub-api); поля — как отдаёт хаб, без переименований
|
||
|
||
export interface BoardIssue {
|
||
key: string; summary: string | null; issue_type: string | null; status: string | null; priority: string | null;
|
||
assignee: string | null; sprint: string | null; duedate: string | null; planned_start: string | null;
|
||
planned_end: string | null; epic_key: string | null; labels: string[]; neighbor: boolean;
|
||
fact_status: string | null; work_kind: string | null;
|
||
}
|
||
export interface BoardLink { source: string; target: string; link_type: string }
|
||
export interface SyncRun { started_at: string; finished_at: string; issues_count: number }
|
||
export interface BoardSnapshot { generated_at: string; last_sync: SyncRun | null; issues: BoardIssue[]; links: BoardLink[] }
|
||
|
||
// словари настроек: kind_labels — вид работы → метка Jira, link_types — логический тип → имя типа связи Jira,
|
||
// status_labels — статус по факту → метка Jira
|
||
export interface HubSettings {
|
||
kind_labels?: Record<string, string>;
|
||
link_types?: Record<string, string>;
|
||
status_labels?: Record<string, string>;
|
||
[key: string]: unknown;
|
||
}
|
||
|
||
export interface IssueRow {
|
||
key: string; id: string | null; issue_type: string | null; summary: string | null; description: string | null;
|
||
status: string | null; priority: string | null; assignee: string | null; reporter: string | null;
|
||
duedate: string | null; planned_start: string | null; planned_end: string | null; epic_key: string | null;
|
||
labels: string[]; sprint: string | null; created: string | null; jira_updated: string | null; fetched_at: string;
|
||
neighbor: boolean; fact_status?: string | null; work_kind?: string | null;
|
||
}
|
||
|
||
export const NOTE_FIELDS = ["fact_status", "work_kind", "done_text", "rest_text", "owner_note"] as const;
|
||
export type NoteField = (typeof NOTE_FIELDS)[number];
|
||
export type NoteFields = Partial<Record<NoteField, string | null>>;
|
||
export type Note = Record<NoteField, string | null> & { key: string; updated_at: string; updated_by: string };
|
||
|
||
export interface IssueCommand {
|
||
id: number; batch_id: number; type: string; status: string; finished_at: string | null; error: string | null;
|
||
}
|
||
export interface IssueDetail extends IssueRow {
|
||
links_out: { target_key: string; link_type: string }[];
|
||
links_in: { source_key: string; link_type: string }[];
|
||
note: Note | null;
|
||
commands: IssueCommand[];
|
||
}
|
||
|
||
export type BatchStatus = "queued" | "running" | "done" | "cancelled";
|
||
export interface BatchCommand {
|
||
id: number; seq: number; type: string; status: string; error: string | null; finished_at: string | null;
|
||
}
|
||
export interface Batch {
|
||
id: number; title: string; author: string; created_at: string; cancelled_at: string | null; status: BatchStatus;
|
||
counts: Record<string, number>; remaining_seconds: number; commands?: BatchCommand[];
|
||
}
|
||
export interface AgentView { online: boolean; last_seen_at: string | null; warning: string | null }
|
||
export interface CreatedBatch { id: number; estimate_seconds: number; count: number }
|
||
```
|
||
|
||
- [ ] **Step 2: Тестовые помощники**
|
||
|
||
`web/src/test/fetchMock.ts`:
|
||
|
||
```ts
|
||
import { vi } from "vitest";
|
||
|
||
export function json(status: number, body: unknown): Response {
|
||
return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } });
|
||
}
|
||
|
||
// подмена fetch: handler получает метод, URL (относительно http://hub.test) и разобранное тело
|
||
export function mockFetch(handler: (method: string, url: URL, body: unknown) => Response | Promise<Response>) {
|
||
const fn = vi.fn(async (input: RequestInfo | URL, init: RequestInit = {}) => {
|
||
const url = new URL(String(input), "http://hub.test");
|
||
const body = typeof init.body === "string" ? JSON.parse(init.body) : undefined;
|
||
return handler(init.method ?? "GET", url, body);
|
||
});
|
||
vi.stubGlobal("fetch", fn);
|
||
return fn;
|
||
}
|
||
```
|
||
|
||
`web/src/test/render.tsx`:
|
||
|
||
```tsx
|
||
import { QueryClientProvider } from "@tanstack/react-query";
|
||
import { render } from "@testing-library/react";
|
||
import type { ReactElement } from "react";
|
||
import { createMemoryRouter, RouterProvider } from "react-router";
|
||
import { makeQueryClient } from "../api/queryClient.ts";
|
||
|
||
// компонент под QueryClient и data-роутером в памяти: path — шаблон маршрута (для useParams), route — открытый адрес
|
||
export function renderWithProviders(ui: ReactElement, { route = "/", path = "*" }: { route?: string; path?: string } = {}) {
|
||
const client = makeQueryClient();
|
||
const router = createMemoryRouter([{ path, element: ui }, { path: "*", element: <p>другая страница</p> }],
|
||
{ initialEntries: [route] });
|
||
return { client, router, ...render(<QueryClientProvider client={client}><RouterProvider router={router} /></QueryClientProvider>) };
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Падающие тесты токена и клиента**
|
||
|
||
`web/src/auth/token.test.ts`:
|
||
|
||
```ts
|
||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||
|
||
describe("token", () => {
|
||
beforeEach(() => { vi.resetModules(); localStorage.clear(); });
|
||
afterEach(() => { vi.restoreAllMocks(); });
|
||
|
||
it("читает токен, сохранённый прежней доской", async () => {
|
||
localStorage.setItem("jira-hub-token", "abc");
|
||
const t = await import("./token.ts");
|
||
expect(t.getToken()).toBe("abc");
|
||
});
|
||
|
||
it("setToken и clearToken пишут в localStorage и оповещают подписчиков", async () => {
|
||
const t = await import("./token.ts");
|
||
const fn = vi.fn();
|
||
t.subscribe(fn);
|
||
t.setToken("xyz");
|
||
expect(localStorage.getItem("jira-hub-token")).toBe("xyz");
|
||
t.clearToken("bad");
|
||
expect(t.getAuth()).toEqual({ token: "", reason: "bad" });
|
||
expect(localStorage.getItem("jira-hub-token")).toBeNull();
|
||
expect(fn).toHaveBeenCalledTimes(2);
|
||
});
|
||
|
||
it("закрытое хранилище: токен живёт в памяти, исключений нет", async () => {
|
||
vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => { throw new DOMException("closed", "SecurityError"); });
|
||
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { throw new DOMException("closed", "SecurityError"); });
|
||
const t = await import("./token.ts");
|
||
expect(t.getToken()).toBe("");
|
||
t.setToken("mem");
|
||
expect(t.getToken()).toBe("mem");
|
||
});
|
||
|
||
it("TOKEN_RE пропускает только печатный ASCII без пробелов", async () => {
|
||
const { TOKEN_RE } = await import("./token.ts");
|
||
expect(TOKEN_RE.test("Ab-_9~")).toBe(true);
|
||
expect(TOKEN_RE.test("ab c")).toBe(false);
|
||
expect(TOKEN_RE.test("токен")).toBe(false);
|
||
});
|
||
});
|
||
```
|
||
|
||
`web/src/api/client.test.ts`:
|
||
|
||
```ts
|
||
import { beforeEach, describe, expect, it } from "vitest";
|
||
import { clearToken, setToken } from "../auth/token.ts";
|
||
import { json, mockFetch } from "../test/fetchMock.ts";
|
||
import { api, ApiError, AuthError, checkToken, HubDownError } from "./client.ts";
|
||
|
||
describe("api", () => {
|
||
beforeEach(() => setToken("tok"));
|
||
|
||
it("шлёт токен и параметры, пустые параметры выбрасывает", async () => {
|
||
const fetch = mockFetch(() => json(200, { issues: [] }));
|
||
await api.get("/issues", { sprint: "S 2", q: "", epic: null, limit: 50 });
|
||
const [url, init] = fetch.mock.calls[0];
|
||
expect(url).toBe("/issues?sprint=S+2&limit=50");
|
||
expect((init!.headers as Record<string, string>).Authorization).toBe("Bearer tok");
|
||
expect(init!.cache).toBe("no-store");
|
||
});
|
||
|
||
it("PUT отправляет JSON", async () => {
|
||
const fetch = mockFetch((_m, _u, body) => json(200, body));
|
||
expect(await api.put("/issues/A-1/note", { rest_text: "x" })).toEqual({ rest_text: "x" });
|
||
expect((fetch.mock.calls[0][1]!.headers as Record<string, string>)["Content-Type"]).toBe("application/json; charset=utf-8");
|
||
});
|
||
|
||
it("401 — AuthError, 5xx и сеть — HubDownError, прочие 4xx — ApiError с detail", async () => {
|
||
mockFetch(() => json(401, { error: "unauthorized" }));
|
||
await expect(api.get("/board")).rejects.toBeInstanceOf(AuthError);
|
||
mockFetch(() => json(503, {}));
|
||
await expect(api.get("/board")).rejects.toBeInstanceOf(HubDownError);
|
||
mockFetch(() => { throw new TypeError("Failed to fetch"); });
|
||
await expect(api.get("/board")).rejects.toBeInstanceOf(HubDownError);
|
||
mockFetch(() => json(422, { error: "bad_note", detail: "неизвестные поля заметки: x" }));
|
||
const e = await api.put("/issues/A-1/note", { x: 1 }).catch((err: unknown) => err);
|
||
expect(e).toBeInstanceOf(ApiError);
|
||
expect(e).toMatchObject({ status: 422, error: "bad_note", detail: "неизвестные поля заметки: x" });
|
||
});
|
||
|
||
it("без токена — AuthError без запроса", async () => {
|
||
clearToken();
|
||
const fetch = mockFetch(() => json(200, {}));
|
||
await expect(api.get("/board")).rejects.toBeInstanceOf(AuthError);
|
||
expect(fetch).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("checkToken проверяет переданный токен, не сохранённый", async () => {
|
||
const fetch = mockFetch(() => json(200, { kind_labels: {} }));
|
||
await checkToken("candidate");
|
||
expect((fetch.mock.calls[0][1]!.headers as Record<string, string>).Authorization).toBe("Bearer candidate");
|
||
expect(fetch.mock.calls[0][0]).toBe("/settings");
|
||
});
|
||
});
|
||
|
||
```
|
||
|
||
Run: `cd web && npx vitest run src/api src/auth`
|
||
Expected: FAIL — модулей `client.ts`, `token.ts` нет.
|
||
|
||
- [ ] **Step 4: `token.ts`, `useAuth.ts`, `client.ts`, `queryClient.ts`**
|
||
|
||
`web/src/auth/token.ts`:
|
||
|
||
```ts
|
||
// клиентский токен хаба: localStorage["jira-hub-token"] (тот же ключ, что у прежней доски) и копия в памяти на случай
|
||
// закрытого хранилища. reason — почему показан экран входа: "bad" — токен не подошёл (ответ 401)
|
||
export const TOKEN_KEY = "jira-hub-token";
|
||
export const TOKEN_RE = /^[\x21-\x7e]+$/; // печатный ASCII без пробелов: иначе fetch бросит TypeError на заголовке
|
||
|
||
export type LoginReason = "none" | "bad";
|
||
export interface AuthSnapshot { token: string; reason: LoginReason }
|
||
|
||
const readStored = (): string => {
|
||
try { return localStorage.getItem(TOKEN_KEY) ?? ""; } catch { return ""; }
|
||
};
|
||
|
||
let state: AuthSnapshot = { token: readStored(), reason: "none" };
|
||
const listeners = new Set<() => void>();
|
||
const emit = (next: AuthSnapshot) => { state = next; listeners.forEach((l) => l()); };
|
||
|
||
export const getAuth = (): AuthSnapshot => state;
|
||
export const getToken = (): string => state.token;
|
||
|
||
export function setToken(token: string): void {
|
||
try { localStorage.setItem(TOKEN_KEY, token); } catch { /* закрытое хранилище: токен только в памяти */ }
|
||
emit({ token, reason: "none" });
|
||
}
|
||
|
||
export function clearToken(reason: LoginReason = "none"): void {
|
||
try { localStorage.removeItem(TOKEN_KEY); } catch { /* закрытое хранилище */ }
|
||
emit({ token: "", reason });
|
||
}
|
||
|
||
export function subscribe(fn: () => void): () => void {
|
||
listeners.add(fn);
|
||
return () => { listeners.delete(fn); };
|
||
}
|
||
```
|
||
|
||
`web/src/auth/useAuth.ts`:
|
||
|
||
```ts
|
||
import { useSyncExternalStore } from "react";
|
||
import { getAuth, subscribe, type AuthSnapshot } from "./token.ts";
|
||
|
||
export const useAuth = (): AuthSnapshot => useSyncExternalStore(subscribe, getAuth);
|
||
```
|
||
|
||
`web/src/api/client.ts`:
|
||
|
||
```ts
|
||
// единственный клиент HTTP API хаба: тот же хост, клиентский токен в Authorization, ответы — JSON.
|
||
// Ошибки: AuthError — 401 или нет токена; HubDownError — сеть или 5xx; ApiError — прочие 4xx с {error, detail}
|
||
import { getToken } from "../auth/token.ts";
|
||
import type { HubSettings } from "./types.ts";
|
||
|
||
export class AuthError extends Error {}
|
||
export class HubDownError extends Error {}
|
||
export class ApiError extends Error {
|
||
readonly status: number;
|
||
readonly error: string;
|
||
readonly detail: string | null;
|
||
constructor(status: number, error: string, detail: string | null) {
|
||
super(detail ? `${error}: ${detail}` : error);
|
||
this.status = status;
|
||
this.error = error;
|
||
this.detail = detail;
|
||
}
|
||
}
|
||
|
||
export type Params = Record<string, string | number | boolean | null | undefined>;
|
||
|
||
function withParams(path: string, params?: Params): string {
|
||
if (!params) return path;
|
||
const q = new URLSearchParams();
|
||
for (const [k, v] of Object.entries(params)) if (v !== null && v !== undefined && v !== "") q.set(k, String(v));
|
||
const s = q.toString();
|
||
return s ? `${path}?${s}` : path;
|
||
}
|
||
|
||
async function call<T>(method: string, path: string, body?: unknown, token = getToken()): Promise<T> {
|
||
if (!token) throw new AuthError("нет токена");
|
||
const headers: Record<string, string> = { Authorization: "Bearer " + token, Accept: "application/json" };
|
||
if (body !== undefined) headers["Content-Type"] = "application/json; charset=utf-8";
|
||
let resp: Response;
|
||
try {
|
||
resp = await fetch(path, { method, headers, body: body === undefined ? undefined : JSON.stringify(body), cache: "no-store" });
|
||
} catch {
|
||
throw new HubDownError("хаб не отвечает");
|
||
}
|
||
if (resp.status === 401) throw new AuthError("токен не подошёл");
|
||
let data: unknown = null;
|
||
try { data = await resp.json(); } catch { /* тело не JSON */ }
|
||
if (resp.status >= 500) throw new HubDownError(`хаб ответил ${resp.status}`);
|
||
if (!resp.ok) {
|
||
const d = (data ?? {}) as { error?: string; detail?: unknown };
|
||
throw new ApiError(resp.status, d.error ?? "http", d.detail === undefined || d.detail === null ? null : String(d.detail));
|
||
}
|
||
return data as T;
|
||
}
|
||
|
||
export const api = {
|
||
get: <T>(path: string, params?: Params) => call<T>("GET", withParams(path, params)),
|
||
post: <T>(path: string, body: unknown = {}) => call<T>("POST", path, body),
|
||
put: <T>(path: string, body: unknown) => call<T>("PUT", path, body),
|
||
};
|
||
|
||
// проверка токена до сохранения: экран входа
|
||
export const checkToken = (token: string) => call<HubSettings>("GET", "/settings", undefined, token);
|
||
```
|
||
|
||
`web/src/api/queryClient.ts`:
|
||
|
||
```ts
|
||
import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query";
|
||
import { clearToken, getToken } from "../auth/token.ts";
|
||
import { AuthError } from "./client.ts";
|
||
|
||
// 401 в любом запросе или действии — экран входа с пояснением «токен не подошёл»
|
||
export function makeQueryClient(): QueryClient {
|
||
const onError = (e: unknown) => { if (e instanceof AuthError) clearToken(getToken() ? "bad" : "none"); };
|
||
return new QueryClient({
|
||
queryCache: new QueryCache({ onError }),
|
||
mutationCache: new MutationCache({ onError }),
|
||
defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false, staleTime: 30_000 } },
|
||
});
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Тест экрана входа**
|
||
|
||
`web/src/auth/LoginScreen.test.tsx`:
|
||
|
||
```tsx
|
||
import { screen, waitFor } from "@testing-library/react";
|
||
import userEvent from "@testing-library/user-event";
|
||
import { beforeEach, describe, expect, it } from "vitest";
|
||
import { json, mockFetch } from "../test/fetchMock.ts";
|
||
import { renderWithProviders } from "../test/render.tsx";
|
||
import { AuthGate } from "./AuthGate.tsx";
|
||
import { clearToken, getAuth } from "./token.ts";
|
||
|
||
const app = <AuthGate><p>страница приложения</p></AuthGate>;
|
||
|
||
describe("вход", () => {
|
||
beforeEach(() => clearToken());
|
||
|
||
it("без токена — экран входа, страница не видна", () => {
|
||
renderWithProviders(app);
|
||
expect(screen.getByRole("heading", { name: "Вход в хаб" })).toBeInTheDocument();
|
||
expect(screen.queryByText("страница приложения")).not.toBeInTheDocument();
|
||
expect(screen.getByText(/uv run jira-hub tokens/)).toBeInTheDocument();
|
||
});
|
||
|
||
it("верный токен сохраняется и открывает приложение", async () => {
|
||
const fetch = mockFetch(() => json(200, {}));
|
||
renderWithProviders(app);
|
||
await userEvent.type(screen.getByLabelText("Клиентский токен хаба"), "good-token");
|
||
await userEvent.click(screen.getByRole("button", { name: "Войти" }));
|
||
expect(await screen.findByText("страница приложения")).toBeInTheDocument();
|
||
expect(localStorage.getItem("jira-hub-token")).toBe("good-token");
|
||
expect(fetch.mock.calls[0][0]).toBe("/settings");
|
||
});
|
||
|
||
it("неверный токен не сохраняется, поле не стирается", async () => {
|
||
mockFetch(() => json(401, { error: "unauthorized" }));
|
||
renderWithProviders(app);
|
||
const input = screen.getByLabelText("Клиентский токен хаба");
|
||
await userEvent.type(input, "wrong");
|
||
await userEvent.click(screen.getByRole("button", { name: "Войти" }));
|
||
expect(await screen.findByText(/Токен не подошёл/)).toBeInTheDocument();
|
||
expect(input).toHaveValue("wrong");
|
||
expect(localStorage.getItem("jira-hub-token")).toBeNull();
|
||
});
|
||
|
||
it("кириллица или пробел — «недопустимые символы», запроса нет", async () => {
|
||
const fetch = mockFetch(() => json(200, {}));
|
||
renderWithProviders(app);
|
||
await userEvent.type(screen.getByLabelText("Клиентский токен хаба"), "ток ен");
|
||
await userEvent.click(screen.getByRole("button", { name: "Войти" }));
|
||
expect(screen.getByText(/недопустимые символы/)).toBeInTheDocument();
|
||
expect(fetch).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("хаб не отвечает — своё сообщение, не «токен не подошёл»", async () => {
|
||
mockFetch(() => { throw new TypeError("Failed to fetch"); });
|
||
renderWithProviders(app);
|
||
await userEvent.type(screen.getByLabelText("Клиентский токен хаба"), "tok");
|
||
await userEvent.click(screen.getByRole("button", { name: "Войти" }));
|
||
expect(await screen.findByText(/Хаб не отвечает/)).toBeInTheDocument();
|
||
});
|
||
|
||
it("после 401 в приложении экран входа поясняет причину", async () => {
|
||
clearToken("bad");
|
||
renderWithProviders(app);
|
||
await waitFor(() => expect(getAuth().reason).toBe("bad"));
|
||
expect(screen.getByText(/Токен не подошёл/)).toBeInTheDocument();
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 6: `LoginScreen.tsx` и `AuthGate.tsx`**
|
||
|
||
`web/src/auth/LoginScreen.tsx` (разметка и тексты — как у `links.html`, строки 287–293 и 1054, 1080; строки про
|
||
загрузку файла без хаба нет — R-9):
|
||
|
||
```tsx
|
||
import { useQueryClient } from "@tanstack/react-query";
|
||
import { useState, type FormEvent } from "react";
|
||
import { AuthError, checkToken } from "../api/client.ts";
|
||
import { setToken, TOKEN_RE } from "./token.ts";
|
||
import { useAuth } from "./useAuth.ts";
|
||
|
||
const BAD_TOKEN = "Токен не подошёл. Проверьте его по uv run jira-hub tokens: токен могли пересоздать.";
|
||
const BAD_CHARS = "В токене недопустимые символы: скопируйте строку после «клиентский токен:» целиком, без пробелов и кириллицы.";
|
||
const HUB_DOWN = "Хаб не отвечает: проверьте, что jira-hub запущен, и попробуйте снова.";
|
||
|
||
export function LoginScreen() {
|
||
const { reason } = useAuth();
|
||
const qc = useQueryClient();
|
||
const [value, setValue] = useState("");
|
||
const [error, setError] = useState(reason === "bad" ? BAD_TOKEN : "");
|
||
const [busy, setBusy] = useState(false);
|
||
|
||
async function submit(ev: FormEvent) {
|
||
ev.preventDefault();
|
||
const t = value.trim();
|
||
if (!t) return;
|
||
if (!TOKEN_RE.test(t)) { setError(BAD_CHARS); return; }
|
||
setBusy(true);
|
||
try {
|
||
await checkToken(t);
|
||
qc.clear();
|
||
setToken(t);
|
||
} catch (e) {
|
||
setError(e instanceof AuthError ? BAD_TOKEN : HUB_DOWN);
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="login">
|
||
<h2>Вход в хаб</h2>
|
||
<p>Приложению нужен клиентский токен хаба. Его показывает команда <code>uv run jira-hub tokens</code> в каталоге
|
||
jira-hub на этом Mac. Токен сохранится в этом браузере.</p>
|
||
{error && <p className="err" role="alert">{error}</p>}
|
||
<form onSubmit={submit}>
|
||
<input type="password" autoComplete="off" spellCheck={false} aria-label="Клиентский токен хаба"
|
||
placeholder="клиентский токен" value={value} onChange={(e) => setValue(e.target.value)} />
|
||
<button className="btn" type="submit" disabled={busy}>Войти</button>
|
||
</form>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
`web/src/auth/AuthGate.tsx`:
|
||
|
||
```tsx
|
||
import type { ReactNode } from "react";
|
||
import { LoginScreen } from "./LoginScreen.tsx";
|
||
import { useAuth } from "./useAuth.ts";
|
||
|
||
// пока нет токена — только экран входа: страниц и их фоновых запросов нет (M-6)
|
||
export function AuthGate({ children }: { children: ReactNode }) {
|
||
const { token } = useAuth();
|
||
return token ? <>{children}</> : <LoginScreen />;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 7: Хуки запросов** — `web/src/api/queries.ts`:
|
||
|
||
```ts
|
||
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||
import { api } from "./client.ts";
|
||
import type { BoardSnapshot, HubSettings, IssueDetail, IssueRow, Note, NoteFields } from "./types.ts";
|
||
|
||
export const ISSUE_FILTER_NAMES = ["sprint", "assignee", "work_kind", "fact_status", "status", "label", "epic", "neighbor", "q"] as const;
|
||
export type IssueFilters = Partial<Record<(typeof ISSUE_FILTER_NAMES)[number], string>>;
|
||
export const ISSUE_PAGE = 50;
|
||
|
||
export const qk = {
|
||
settings: ["settings"] as const,
|
||
board: ["board"] as const,
|
||
issues: (f: IssueFilters) => ["issues", f] as const,
|
||
issue: (key: string) => ["issue", key] as const,
|
||
};
|
||
|
||
export const useSettings = () =>
|
||
useQuery({ queryKey: qk.settings, queryFn: () => api.get<HubSettings>("/settings"), staleTime: Infinity });
|
||
|
||
export const useBoard = () => useQuery({ queryKey: qk.board, queryFn: () => api.get<BoardSnapshot>("/board") });
|
||
|
||
export const useIssue = (key: string) =>
|
||
useQuery({ queryKey: qk.issue(key), queryFn: () => api.get<IssueDetail>(`/issues/${encodeURIComponent(key)}`) });
|
||
|
||
// список порциями по ISSUE_PAGE: следующая порция есть, пока последняя полная
|
||
export const useIssues = (filters: IssueFilters) =>
|
||
useInfiniteQuery({
|
||
queryKey: qk.issues(filters),
|
||
initialPageParam: 0,
|
||
queryFn: ({ pageParam }) =>
|
||
api.get<{ issues: IssueRow[] }>("/issues", { ...filters, limit: ISSUE_PAGE, offset: pageParam }).then((r) => r.issues),
|
||
getNextPageParam: (last, all) => (last.length < ISSUE_PAGE ? undefined : all.length * ISSUE_PAGE),
|
||
});
|
||
|
||
// сохранение заметки: key фиксируется при создании мутации — ответ пишется в карточку той задачи, для которой отправлен
|
||
export function useSaveNote(key: string) {
|
||
const qc = useQueryClient();
|
||
return useMutation({
|
||
mutationFn: (fields: NoteFields) =>
|
||
api.put<Note>(`/issues/${encodeURIComponent(key)}/note`, { ...fields, updated_by: "web" }),
|
||
onSuccess: (note) => {
|
||
qc.setQueryData<IssueDetail>(qk.issue(key), (d) => (d ? { ...d, note } : d));
|
||
void qc.invalidateQueries({ queryKey: ["issues"] });
|
||
void qc.invalidateQueries({ queryKey: qk.board });
|
||
},
|
||
});
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 8: Прогнать**
|
||
|
||
Run: `cd web && npm run lint && npm test`
|
||
Expected: PASS — `token.test.ts` 4, `client.test.ts` 5, `LoginScreen.test.tsx` 6, прежние 3.
|
||
|
||
- [ ] **Step 9: Commit**
|
||
|
||
```bash
|
||
git add web/src/api web/src/auth web/src/test
|
||
git commit -m "feat(web): клиент API, токен и экран входа, хуки запросов (#21)
|
||
|
||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: оболочка — маршруты под `/app`, панель разделов, ошибки, тема, базовые стили
|
||
|
||
**Модель:** haiku (код готов; стили — перенос по строкам).
|
||
|
||
**Files:**
|
||
- Create: `web/src/shell/Layout.tsx`, `web/src/shell/ErrorBoundary.tsx`, `web/src/shell/NotFound.tsx`,
|
||
`web/src/shell/theme.ts`, `web/src/shell/shell.css`, `web/src/styles/theme.css`, `web/src/styles/base.css`,
|
||
`web/src/styles/controls.css`, `web/src/routes.tsx`, временные `web/src/board/BoardPage.tsx`,
|
||
`web/src/issues/IssuesPage.tsx`, `web/src/issues/IssuePage.tsx` (заменяются в Task 11, 13, 14)
|
||
- Modify: `web/src/App.tsx`, `web/src/main.tsx`, `web/src/App.test.tsx`
|
||
- Test: `web/src/App.test.tsx`, `web/src/shell/theme.test.ts`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `AuthGate`, `makeQueryClient`, `clearToken` (Task 3).
|
||
- Produces: `routes: RouteObject[]` (`routes.tsx`); `App({router?})`; `theme.ts`: `THEMES = ["", "light", "dark"]`,
|
||
`THEME_NAMES`, `type Theme`, `readTheme(): Theme`, `applyTheme(t: Theme): void`, `saveTheme(t: Theme): void` — ключ
|
||
`graph-theme`, значение `""|"light"|"dark"` (как у `links.html`, строки 1000–1003).
|
||
Страницы: `BoardPage` (`web/src/board/BoardPage.tsx`), `IssuesPage`, `IssuePage` — экспорт по имени.
|
||
|
||
- [ ] **Step 1: Стили из `links.html`**
|
||
|
||
Перенести CSS ревизии 3931a88 как есть (кроме перечисленных правок):
|
||
- `web/src/styles/theme.css` ← строки 12–30 (раздел 1, цвета и темы);
|
||
- `web/src/styles/base.css` ← строки 32–40 (раздел 2), но в правиле `body` убрать `padding-inline` и `padding-block`
|
||
(отступы даёт `.page`); добавить в конец:
|
||
`.page{padding-inline:16px;padding-block:0 32px}` и
|
||
`.login{max-width:560px;margin:48px auto}.login form{display:flex;gap:8px}.login input{flex:1;font:inherit;height:34px;padding:0 10px;border:1px solid var(--line);border-radius:6px;background:var(--card);color:var(--fg)}.login .err{color:var(--p-crit)}`;
|
||
- `web/src/styles/controls.css` ← строки 53–91 (раздел 4, кнопки и выпадающие списки) и из раздела 3 правила `.link`,
|
||
`.link:hover,.link:focus-visible`, `.link:disabled` (строки 47–49).
|
||
|
||
`web/src/shell/shell.css`:
|
||
|
||
```css
|
||
/* оболочка: верхняя панель разделов */
|
||
.appbar{display:flex;align-items:center;gap:16px;padding:10px 16px;border-bottom:1px solid var(--line);background:var(--panel)}
|
||
.appbar .brand{font-weight:600}
|
||
.appbar nav{display:flex;gap:4px}
|
||
.appbar nav a{color:var(--fg);text-decoration:none;padding:6px 10px;border-radius:6px}
|
||
.appbar nav a.active{background:var(--accent);color:var(--accent-fg)}
|
||
.appbar .spacer{flex:1 1 auto}
|
||
.crash{max-width:640px;margin:48px auto}
|
||
```
|
||
|
||
- [ ] **Step 2: Тема** — тест `web/src/shell/theme.test.ts`:
|
||
|
||
```ts
|
||
import { describe, expect, it } from "vitest";
|
||
import { applyTheme, readTheme, saveTheme } from "./theme.ts";
|
||
|
||
describe("тема", () => {
|
||
it("читает graph-theme, неизвестное значение — системная", () => {
|
||
localStorage.setItem("graph-theme", "dark");
|
||
expect(readTheme()).toBe("dark");
|
||
localStorage.setItem("graph-theme", "purple");
|
||
expect(readTheme()).toBe("");
|
||
});
|
||
it("applyTheme ставит и снимает data-theme, saveTheme пишет ключ", () => {
|
||
applyTheme("light");
|
||
expect(document.documentElement.getAttribute("data-theme")).toBe("light");
|
||
applyTheme("");
|
||
expect(document.documentElement.hasAttribute("data-theme")).toBe(false);
|
||
saveTheme("dark");
|
||
expect(localStorage.getItem("graph-theme")).toBe("dark");
|
||
});
|
||
});
|
||
```
|
||
|
||
`web/src/shell/theme.ts`:
|
||
|
||
```ts
|
||
// тема интерфейса: "" — системная, light, dark; ключ graph-theme, как у прежней доски (links.html, строки 1000–1003)
|
||
export const THEMES = ["", "light", "dark"] as const;
|
||
export type Theme = (typeof THEMES)[number];
|
||
export const THEME_NAMES: Record<Theme, string> = { "": "системная", light: "светлая", dark: "тёмная" };
|
||
const KEY = "graph-theme";
|
||
|
||
export function readTheme(): Theme {
|
||
let v: string | null = null;
|
||
try { v = localStorage.getItem(KEY); } catch { /* закрытое хранилище */ }
|
||
return (THEMES as readonly string[]).includes(v ?? "") ? (v as Theme) : "";
|
||
}
|
||
|
||
export function saveTheme(t: Theme): void {
|
||
try { localStorage.setItem(KEY, t); } catch { /* закрытое хранилище */ }
|
||
}
|
||
|
||
export function applyTheme(t: Theme): void {
|
||
if (t) document.documentElement.setAttribute("data-theme", t);
|
||
else document.documentElement.removeAttribute("data-theme");
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Тест оболочки** — заменить `web/src/App.test.tsx`:
|
||
|
||
```tsx
|
||
import { screen } from "@testing-library/react";
|
||
import { render } 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 { clearToken, getAuth, setToken } from "./auth/token.ts";
|
||
import { routes } from "./routes.tsx";
|
||
import { json, mockFetch } from "./test/fetchMock.ts";
|
||
|
||
const at = (path: string) => createMemoryRouter(routes, { initialEntries: [path], basename: "/app" });
|
||
|
||
describe("оболочка", () => {
|
||
beforeEach(() => { setToken("tok"); mockFetch(() => json(200, {})); });
|
||
|
||
it("/app ведёт на доску", async () => {
|
||
render(<App router={at("/app")} />);
|
||
expect(await screen.findByTestId("page-board")).toBeInTheDocument();
|
||
});
|
||
|
||
it("переход «Задачи» и обратно", async () => {
|
||
const router = at("/app/board");
|
||
render(<App router={router} />);
|
||
await userEvent.click(screen.getByRole("link", { name: "Задачи" }));
|
||
expect(router.state.location.pathname).toBe("/app/issues");
|
||
expect(await screen.findByTestId("page-issues")).toBeInTheDocument();
|
||
});
|
||
|
||
it("неизвестный путь — «не найдено» со ссылками", async () => {
|
||
render(<App router={at("/app/nope")} />);
|
||
expect(await screen.findByRole("heading", { name: "Страница не найдена" })).toBeInTheDocument();
|
||
});
|
||
|
||
it("«Сменить токен» показывает экран входа", async () => {
|
||
render(<App router={at("/app/board")} />);
|
||
await userEvent.click(screen.getByRole("button", { name: "Сменить токен" }));
|
||
expect(getAuth().token).toBe("");
|
||
expect(screen.getByRole("heading", { name: "Вход в хаб" })).toBeInTheDocument();
|
||
});
|
||
|
||
it("исключение в странице — сообщение и «Перезагрузить», панель на месте", async () => {
|
||
const Boom = () => { throw new Error("сломалось"); };
|
||
const router = createMemoryRouter([{ path: "/", element: <App.Layout />, children: [{ path: "x", element: <Boom /> }] }],
|
||
{ initialEntries: ["/x"] });
|
||
render(<App router={router} />);
|
||
expect(await screen.findByText(/сломалось/)).toBeInTheDocument();
|
||
expect(screen.getByRole("button", { name: "Перезагрузить" })).toBeInTheDocument();
|
||
expect(screen.getByRole("link", { name: "Доска" })).toBeInTheDocument();
|
||
});
|
||
|
||
it("без токена — только экран входа", () => {
|
||
clearToken();
|
||
render(<App router={at("/app/board")} />);
|
||
expect(screen.queryByRole("link", { name: "Доска" })).not.toBeInTheDocument();
|
||
});
|
||
});
|
||
```
|
||
|
||
Исключение в `Boom` React дополнительно печатает в консоль — это ожидаемо; при желании заглушить
|
||
`vi.spyOn(console, "error").mockImplementation(() => {})` в этом тесте.
|
||
|
||
- [ ] **Step 4: Реализация**
|
||
|
||
`web/src/shell/ErrorBoundary.tsx`:
|
||
|
||
```tsx
|
||
import { Component, type ErrorInfo, type ReactNode } from "react";
|
||
|
||
interface State { error: Error | null }
|
||
|
||
// неожиданная ошибка отрисовки страницы: сообщение и перезагрузка вместо пустого экрана; панель разделов остаётся
|
||
export class ErrorBoundary extends Component<{ children: ReactNode }, State> {
|
||
state: State = { error: null };
|
||
static getDerivedStateFromError(error: Error): State { return { error }; }
|
||
componentDidCatch(error: Error, info: ErrorInfo) { console.error("ошибка страницы", error, info.componentStack); }
|
||
render() {
|
||
if (!this.state.error) return this.props.children;
|
||
return (
|
||
<div className="crash" role="alert">
|
||
<h2>Страница сломалась</h2>
|
||
<p>{this.state.error.message}</p>
|
||
<button className="btn" type="button" onClick={() => window.location.reload()}>Перезагрузить</button>
|
||
</div>
|
||
);
|
||
}
|
||
}
|
||
```
|
||
|
||
`web/src/shell/NotFound.tsx`:
|
||
|
||
```tsx
|
||
import { Link } from "react-router";
|
||
|
||
export function NotFound() {
|
||
return (
|
||
<div className="crash">
|
||
<h2>Страница не найдена</h2>
|
||
<p>Разделы приложения: <Link to="/board">доска</Link>, <Link to="/issues">задачи</Link>.</p>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
`web/src/shell/Layout.tsx`:
|
||
|
||
```tsx
|
||
import { useQueryClient } from "@tanstack/react-query";
|
||
import { NavLink, Outlet, useLocation } from "react-router";
|
||
import { clearToken } from "../auth/token.ts";
|
||
import { ErrorBoundary } from "./ErrorBoundary.tsx";
|
||
import "./shell.css";
|
||
|
||
export function Layout() {
|
||
const qc = useQueryClient();
|
||
const { pathname } = useLocation();
|
||
return (
|
||
<>
|
||
<header className="appbar">
|
||
<span className="brand">Хаб Jira</span>
|
||
<nav>
|
||
<NavLink to="/board">Доска</NavLink>
|
||
<NavLink to="/issues" end={false}>Задачи</NavLink>
|
||
</nav>
|
||
<span className="spacer" />
|
||
<button type="button" className="link" onClick={() => { clearToken(); qc.clear(); }}>Сменить токен</button>
|
||
</header>
|
||
<main className="page">
|
||
<ErrorBoundary key={pathname}><Outlet /></ErrorBoundary>
|
||
</main>
|
||
</>
|
||
);
|
||
}
|
||
```
|
||
|
||
Временные страницы (заменяются в Task 11, 13, 14):
|
||
|
||
```tsx
|
||
// web/src/board/BoardPage.tsx
|
||
export function BoardPage() { return <div data-testid="page-board" />; }
|
||
// web/src/issues/IssuesPage.tsx
|
||
export function IssuesPage() { return <div data-testid="page-issues" />; }
|
||
// web/src/issues/IssuePage.tsx
|
||
export function IssuePage() { return <div data-testid="page-issue" />; }
|
||
```
|
||
|
||
`web/src/routes.tsx`:
|
||
|
||
```tsx
|
||
import { Navigate, type RouteObject } from "react-router";
|
||
import { BoardPage } from "./board/BoardPage.tsx";
|
||
import { IssuePage } from "./issues/IssuePage.tsx";
|
||
import { IssuesPage } from "./issues/IssuesPage.tsx";
|
||
import { Layout } from "./shell/Layout.tsx";
|
||
import { NotFound } from "./shell/NotFound.tsx";
|
||
|
||
// адреса относительно basename "/app": /app → /app/board
|
||
export const routes: RouteObject[] = [{
|
||
path: "/",
|
||
element: <Layout />,
|
||
children: [
|
||
{ index: true, element: <Navigate to="/board" replace /> },
|
||
{ path: "board", element: <BoardPage /> },
|
||
{ path: "issues", element: <IssuesPage /> },
|
||
{ path: "issues/:key", element: <IssuePage /> },
|
||
{ path: "*", element: <NotFound /> },
|
||
],
|
||
}];
|
||
```
|
||
|
||
`web/src/App.tsx`:
|
||
|
||
```tsx
|
||
import { QueryClientProvider } from "@tanstack/react-query";
|
||
import { useState } from "react";
|
||
import { createBrowserRouter, RouterProvider } from "react-router";
|
||
import { makeQueryClient } from "./api/queryClient.ts";
|
||
import { AuthGate } from "./auth/AuthGate.tsx";
|
||
import { routes } from "./routes.tsx";
|
||
import { Layout } from "./shell/Layout.tsx";
|
||
import "./styles/theme.css";
|
||
import "./styles/base.css";
|
||
import "./styles/controls.css";
|
||
|
||
type Router = ReturnType<typeof createBrowserRouter>;
|
||
|
||
export function App({ router }: { router?: Router }) {
|
||
const [client] = useState(makeQueryClient);
|
||
const [r] = useState(() => router ?? createBrowserRouter(routes, { basename: "/app" }));
|
||
return (
|
||
<QueryClientProvider client={client}>
|
||
<AuthGate><RouterProvider router={r} /></AuthGate>
|
||
</QueryClientProvider>
|
||
);
|
||
}
|
||
App.Layout = Layout;
|
||
```
|
||
|
||
`web/src/main.tsx` — перед `createRoot` добавить применение сохранённой темы:
|
||
|
||
```tsx
|
||
import { applyTheme, readTheme } from "./shell/theme.ts";
|
||
applyTheme(readTheme());
|
||
```
|
||
|
||
- [ ] **Step 5: Прогнать и собрать**
|
||
|
||
Run: `cd web && npm run lint && npm test && npm run build`
|
||
Expected: PASS (оболочка 6, тема 2, прежние); сборка в `src/jira_hub/web/app/`.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add web/src
|
||
git commit -m "feat(web): оболочка приложения — маршруты /app, разделы, ошибки страниц, тема (#21)
|
||
|
||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
### Task 5: golden-эталоны доски — синтетические фикстуры и генератор из `links.html@3931a88`
|
||
|
||
**Модель:** sonnet (заглушка DOM и прогон чужого скрипта в `node:vm`).
|
||
|
||
**Files:**
|
||
- Create: `web/src/board/__fixtures__/hub-basic.board.json`, `hub-empty.board.json`, `settings.json`,
|
||
`scenarios.json`, `jira-export.csv` (BOM, CRLF), `jira-no-links.csv`, `jira-bad.csv`, `old-board.json`,
|
||
`geometry.cases.json`, `storage.cases.json`
|
||
- Create: `web/scripts/golden-from-links.ts`
|
||
- Create (генерируются скриптом и коммитятся): `web/src/board/__fixtures__/hub-basic.expected.json`,
|
||
`hub-empty.expected.json`, `jira-export.expected.json`, `jira-no-links.expected.json`, `jira-bad.expected.json`,
|
||
`old-board.expected.json`, `geometry.expected.json`, `storage.expected.json`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `canon` (Task 2).
|
||
- Produces: эталоны, которые читают тесты Task 6–9. Формат каждого `*.expected.json` — `JSON.stringify(canon(v), null, 1)`.
|
||
Структура `hub-basic.expected.json`:
|
||
`{dicts: {kindLabels, linkTypes}, model, facets: {default, rememberedAll, rememberedSome}, scenarios: {<name>: {visible,
|
||
emptyReason, layout: {rel, laneOf, colBounds, baseW, pos, lanes, board: {width, height}, clamp, dateCols, dateLabels,
|
||
edges}}}, export}`. Фиксированная дата всех вычислений — `2026-10-14T12:00:00.000Z` (`today = "2026-10-14"`).
|
||
Высоты карточек в заглушке DOM: карточка — 117 px (`measuredHeight` → 120), заголовок карточки — 38 px (`other` = 79).
|
||
|
||
- [ ] **Step 1: Фикстуры данных**
|
||
|
||
`web/src/board/__fixtures__/settings.json` — словари из `admin/jira-sync/config.example.json` (как в настройках живого хаба):
|
||
|
||
```json
|
||
{
|
||
"kind_labels": {"аналитика": "kind-analytics", "решение": "kind-decision", "актуализация": "kind-docs", "разработка": "kind-dev", "UI": "kind-ui", "тестирование": "kind-test", "организационная": "kind-org"},
|
||
"link_types": {"блокирует начало": "ASUPR_Блокер_Начала", "блокирует завершение": "ASUPR_Блокер_Завершения", "связанные": "ASUPR_Связанные"},
|
||
"status_labels": {"Готово — закрыть": "fact-done", "В работе": "fact-in-progress", "Ожидание разработки": "fact-open"}
|
||
}
|
||
```
|
||
|
||
`web/src/board/__fixtures__/hub-basic.board.json` (что покрывает: эпик из снимка и эпик из запасного словаря `EPICS`
|
||
(AURORA-1359 «Гудвилл»), неизвестный эпик, задача без эпика, сосед, заглушка «Нет в зеркале», три типа связей, дубль
|
||
«связанных» в обратную сторону, связь с эпиком, неизвестный тип, ключ не вида AURORA-1234, совпадающие сроки, блокер позже
|
||
зависимой, блокер без исполнителя, не начатый блокер у близкого срока, передачи, цепочка для критического пути, четыре
|
||
спринта и «без спринта», логин вместо ФИО):
|
||
|
||
```json
|
||
{
|
||
"generated_at": "2026-10-14T10:00:00+00:00",
|
||
"last_sync": {"started_at": "2026-10-14T09:00:00+00:00", "finished_at": "2026-10-14T09:01:00+00:00", "issues_count": 10},
|
||
"issues": [
|
||
{"key": "AURORA-9100", "summary": "Тестовый эпик", "issue_type": "Фича", "status": "В работе", "priority": "Средний", "assignee": null, "sprint": null, "duedate": null, "planned_start": null, "planned_end": null, "epic_key": null, "labels": ["AWPCA"], "neighbor": false, "fact_status": null, "work_kind": null},
|
||
{"key": "AURORA-9001", "summary": "Схема свода", "issue_type": "Задача", "status": "В работе", "priority": "Высокий", "assignee": "Иванов Иван Иванович", "sprint": "AWPCA_Спринт 2", "duedate": "2026-10-30", "planned_start": null, "planned_end": "2026-10-20", "epic_key": "AURORA-9100", "labels": ["AWPCA", "kind-dev"], "neighbor": false, "fact_status": "В работе", "work_kind": "разработка"},
|
||
{"key": "AURORA-9002", "summary": "API свода", "issue_type": "Задача", "status": "Ожидание разработки", "priority": "Средний", "assignee": "Петров Пётр Петрович", "sprint": "AWPCA_Спринт 2", "duedate": "2026-10-20", "planned_start": null, "planned_end": null, "epic_key": "AURORA-9100", "labels": ["kind-dev"], "neighbor": false, "fact_status": null, "work_kind": null},
|
||
{"key": "AURORA-9003", "summary": "Макет экрана", "issue_type": "Задача", "status": "Backlog", "priority": "Критичный", "assignee": null, "sprint": "AWPCA_Спринт 2", "duedate": null, "planned_start": null, "planned_end": "2026-10-16", "epic_key": "AURORA-9100", "labels": ["kind-ui"], "neighbor": false, "fact_status": null, "work_kind": null},
|
||
{"key": "AURORA-9004", "summary": "Решение по порогам", "issue_type": "Задача", "status": "К выполнению", "priority": "Наивысший", "assignee": "Сидорова Анна", "sprint": "AWPCA_Спринт 3", "duedate": null, "planned_start": null, "planned_end": "2026-10-25", "epic_key": "AURORA-1359", "labels": ["kind-decision"], "neighbor": false, "fact_status": null, "work_kind": null},
|
||
{"key": "AURORA-9005", "summary": "Тест-кейсы", "issue_type": "Задача", "status": "Ожидание", "priority": "Низкий", "assignee": "Петров Пётр Петрович", "sprint": "AWPCA_Спринт 3", "duedate": null, "planned_start": null, "planned_end": null, "epic_key": "AURORA-1359", "labels": ["kind-test"], "neighbor": false, "fact_status": null, "work_kind": null},
|
||
{"key": "AURORA-9006", "summary": "Задача без эпика", "issue_type": "Задача", "status": "Готово", "priority": "Средний", "assignee": "Иванов Иван Иванович", "sprint": null, "duedate": "2026-10-10", "planned_start": null, "planned_end": null, "epic_key": null, "labels": [], "neighbor": false, "fact_status": null, "work_kind": null},
|
||
{"key": "AURORA-9007", "summary": "Сосед по связям", "issue_type": "Задача", "status": "В работе", "priority": "Средний", "assignee": "Kozlov-AA", "sprint": "AWPCA_Спринт 1", "duedate": null, "planned_start": null, "planned_end": "2026-10-12", "epic_key": "AURORA-9100", "labels": ["kind-analytics"], "neighbor": true, "fact_status": null, "work_kind": null},
|
||
{"key": "AURORA-9008", "summary": "Актуализация ТЗ", "issue_type": "Задача", "status": "Ожидание разработки", "priority": "Самый низкий", "assignee": "Иванов Иван Иванович", "sprint": "AWPCA_Спринт 2", "duedate": null, "planned_start": null, "planned_end": "2026-10-18", "epic_key": "AURORA-9999", "labels": ["kind-docs"], "neighbor": false, "fact_status": null, "work_kind": null},
|
||
{"key": "AURORA-9009", "summary": "Созвон с заказчиком", "issue_type": "Задача", "status": "", "priority": "Средний", "assignee": "Иванов Иван Иванович", "sprint": "AWPCA_Спринт 2", "duedate": null, "planned_start": null, "planned_end": "2026-10-20", "epic_key": "AURORA-9100", "labels": ["kind-org"], "neighbor": false, "fact_status": null, "work_kind": null}
|
||
],
|
||
"links": [
|
||
{"source": "AURORA-9001", "target": "AURORA-9002", "link_type": "ASUPR_Блокер_Начала"},
|
||
{"source": "AURORA-9003", "target": "AURORA-9001", "link_type": "ASUPR_Блокер_Начала"},
|
||
{"source": "AURORA-9002", "target": "AURORA-9005", "link_type": "ASUPR_Блокер_Завершения"},
|
||
{"source": "AURORA-9004", "target": "AURORA-9002", "link_type": "ASUPR_Блокер_Начала"},
|
||
{"source": "AURORA-9001", "target": "AURORA-9008", "link_type": "ASUPR_Связанные"},
|
||
{"source": "AURORA-9008", "target": "AURORA-9001", "link_type": "ASUPR_Связанные"},
|
||
{"source": "AURORA-9006", "target": "AURORA-9500", "link_type": "ASUPR_Блокер_Начала"},
|
||
{"source": "AURORA-9001", "target": "AURORA-9100", "link_type": "ASUPR_Блокер_Начала"},
|
||
{"source": "AURORA-9009", "target": "AURORA-9002", "link_type": "Duplicate"},
|
||
{"source": "AURORA-9001", "target": "lower-1", "link_type": "ASUPR_Связанные"},
|
||
{"source": "AURORA-9007", "target": "AURORA-9009", "link_type": "ASUPR_Блокер_Начала"}
|
||
]
|
||
}
|
||
```
|
||
|
||
`web/src/board/__fixtures__/hub-empty.board.json`:
|
||
|
||
```json
|
||
{"generated_at": "2026-10-14T10:00:00+00:00", "last_sync": null, "issues": [], "links": []}
|
||
```
|
||
|
||
`web/src/board/__fixtures__/scenarios.json` — наборы фильтров и сохранённого состояния доски; отсутствующие поля —
|
||
значения по умолчанию (флаги `false`, множества пустые, хранилище пустое):
|
||
|
||
```json
|
||
[
|
||
{"name": "default", "filters": {}},
|
||
{"name": "sprint-2", "filters": {"sprints": ["AWPCA_Спринт 2"]}},
|
||
{"name": "sprint-2-neighbors", "filters": {"sprints": ["AWPCA_Спринт 2"], "neighbors": true}},
|
||
{"name": "who-ivanov", "filters": {"who": ["Иванов Иван Иванович"], "neighbors": true}},
|
||
{"name": "hand-linked", "filters": {"onlyHand": true, "onlyLinked": true}},
|
||
{"name": "crit", "filters": {"crit": true}},
|
||
{"name": "hide", "filters": {"hideKind": ["dev"], "hideEdge": ["rel"]}},
|
||
{"name": "empty", "filters": {"who": ["Никто Никтович"]}},
|
||
{"name": "lane-order", "filters": {}, "laneOrder": ["Гудвилл", "Без эпика", "Тестовый эпик"]},
|
||
{"name": "by-date", "filters": {"byDate": true}},
|
||
{"name": "saved-folded", "filters": {}, "saved": {"AURORA-9002": {"x": 600, "ry": 80, "w": 280, "h": 160}, "AURORA-9003": {"x": 30, "ry": 50}}, "folded": ["Гудвилл"]},
|
||
{"name": "by-date-colw", "filters": {"byDate": true}, "colWidths": {"2026-10-20": 400}, "savedDate": {"AURORA-9001": {"x": 10, "ry": 60, "w": 250}}}
|
||
]
|
||
```
|
||
|
||
CSV-фикстуры создать командой (BOM и CRLF у `jira-export.csv` — часть проверки разбора):
|
||
|
||
```bash
|
||
python3 - <<'EOF'
|
||
from pathlib import Path
|
||
d = Path("web/src/board/__fixtures__")
|
||
rows = [
|
||
"Ключ проблемы,Тема,Статус,Приоритет,Тип задачи,Метки,Метки,Связи Фичи,Плановая дата окончания,Исполнитель,Спринт,Спринт,"
|
||
"Внешняя ссылка на проблему (ASUPR_Блокер_Начала),Внутренняя ссылка на проблему (ASUPR_Блокер_Начала),"
|
||
"Внешняя ссылка на проблему (ASUPR_Связанные)",
|
||
"AURORA-9100,Тестовый эпик,В работе,Средний,Фича,AWPCA,,,,,,,,,",
|
||
'AURORA-9001,"Схема свода, черновик",В работе,Высокий,Задача,AWPCA,kind-dev,AURORA-9100,20.10.2026,Иванов Иван Иванович,'
|
||
"AWPCA_Спринт 1,AWPCA_Спринт 2,AURORA-9002,AURORA-9003,AURORA-9008",
|
||
'AURORA-9002,"API ""свода""",Ожидание разработки,Средний,Задача,kind-dev,,AURORA-9100,20/окт/26,Петров Пётр Петрович,'
|
||
"AWPCA_Спринт 2,,,,",
|
||
"AURORA-9003,Макет экрана,Backlog,Критичный,Задача,kind-ui,,AURORA-1359,2026-10-16,,AWPCA_Спринт 2,,AURORA-9001,,",
|
||
"AURORA-9006,Задача без эпика,Готово,Средний,Задача,,,,10/Oct/26 12:00 PM,Иванов Иван Иванович,,,AURORA-9500,,",
|
||
"not-a-key,мусор,,,,,,,,,,,,,",
|
||
"",
|
||
]
|
||
(d / "jira-export.csv").write_bytes(("" + "\r\n".join(rows)).encode("utf-8"))
|
||
(d / "jira-no-links.csv").write_text("Issue key,Summary,Status\nAURORA-9001,Тема,Open\n", encoding="utf-8")
|
||
(d / "jira-bad.csv").write_text("Номер,Название\n1,x\n", encoding="utf-8")
|
||
EOF
|
||
```
|
||
|
||
`web/src/board/__fixtures__/old-board.json` — выгрузка старой страницы (старый `mode`, битая раскладка, дубль ключа,
|
||
заглушка, неизвестный тип связи, связь на ключ вне файла):
|
||
|
||
```json
|
||
{"format": "arm-ka-links/1", "exportedAt": "2026-10-11T08:00:00.000Z", "source": "sprint2.csv",
|
||
"filters": {"mode": "linked", "sprints": ["AWPCA_Спринт 2"], "assignees": ["Петров Пётр Петрович"], "onlyHand": true, "criticalPath": false, "byDate": true, "hiddenKinds": ["org"], "hiddenEdges": []},
|
||
"laneOrder": ["Гудвилл", "Тестовый эпик"], "collapsedLanes": ["Без эпика"], "dateColumns": {"2026-10-20": 360},
|
||
"nodes": [
|
||
{"key": "AURORA-9001", "title": "Схема свода", "status": "В работе", "priority": "Высокий", "kind": "разработка", "due": "2026-10-20", "assignee": "Иванов Иван Иванович", "sprint": "AWPCA_Спринт 2", "epic": "Тестовый эпик", "layout": {"x": 24, "ry": 40, "w": 240}, "layoutByDate": {"x": 6, "ry": 40, "w": 240, "h": 144}},
|
||
{"key": "AURORA-9002", "title": "API свода", "status": "Ожидание разработки", "priority": "Средний", "kind": "разработка", "due": "20.10.2026", "assignee": "Петров Пётр Петрович", "sprint": "AWPCA_Спринт 2", "epic": "Тестовый эпик", "layout": {"x": "плохо", "ry": 40}},
|
||
{"key": "AURORA-9001", "title": "дубль", "epic": "x"},
|
||
{"key": "AURORA-9500", "stub": true},
|
||
{"key": "bad key"}
|
||
],
|
||
"links": [{"from": "AURORA-9001", "to": "AURORA-9002", "type": "блокирует начало"}, {"from": "AURORA-9002", "to": "AURORA-9777", "type": "неизвестный"}, {"from": "x", "to": "AURORA-9001", "type": "связанные"}]}
|
||
```
|
||
|
||
`web/src/board/__fixtures__/geometry.cases.json`:
|
||
|
||
```json
|
||
{"edge": [
|
||
{"a": {"x": 24, "y": 40, "w": 240, "h": 120}, "b": {"x": 320, "y": 40, "w": 240, "h": 120}},
|
||
{"a": {"x": 24, "y": 40, "w": 240, "h": 120}, "b": {"x": 320, "y": 200, "w": 240, "h": 96}},
|
||
{"a": {"x": 620, "y": 40, "w": 240, "h": 120}, "b": {"x": 24, "y": 300, "w": 240, "h": 120}},
|
||
{"a": {"x": 24, "y": 40, "w": 240, "h": 120}, "b": {"x": 100, "y": 100, "w": 240, "h": 120}},
|
||
{"a": {"x": 24, "y": 40, "w": 240, "h": 120}, "b": {"x": 30, "y": 400, "w": 240, "h": 120}},
|
||
{"a": {"x": 24, "y": 400, "w": 240, "h": 120}, "b": {"x": 24, "y": 40, "w": 240, "h": 120}}],
|
||
"ortho": [[{"x": 0, "y": 0}, {"x": 10, "y": 0}], [{"x": 0, "y": 0}, {"x": 1, "y": 0}, {"x": 1, "y": 30}, {"x": 40, "y": 30}]],
|
||
"align": [
|
||
{"pos": {"A": {"x": 24, "y": 40, "w": 240, "h": 120}, "B": {"x": 320, "y": 200, "w": 240, "h": 96}}, "key": "B", "r": {"x": 28, "y": 203, "w": 240, "h": 96}, "resizing": false},
|
||
{"pos": {"A": {"x": 24, "y": 40, "w": 240, "h": 120}, "B": {"x": 320, "y": 200, "w": 240, "h": 96}}, "key": "B", "r": {"x": 320, "y": 200, "w": 262, "h": 98}, "resizing": true},
|
||
{"pos": {"A": {"x": 24, "y": 40, "w": 240, "h": 120}}, "key": "B", "r": {"x": 500, "y": 500, "w": 240, "h": 96}, "resizing": false}]}
|
||
```
|
||
|
||
`web/src/board/__fixtures__/storage.cases.json` (`op: "migrate"` — только `migrateStore()`; `"startup"` — код строки 1203
|
||
и затем `migrateStore()`):
|
||
|
||
```json
|
||
[
|
||
{"name": "migrate-largest", "op": "migrate", "before": {"graph-pos:old.csv": "{\"AURORA-9001\":{\"x\":1,\"ry\":40}}", "graph-pos:new.csv": "{\"AURORA-9001\":{\"x\":1,\"ry\":40},\"AURORA-9002\":{\"x\":300,\"ry\":40}}", "graph-lanes:new.csv": "[\"Гудвилл\"]", "graph-fold:old.csv": "[\"x\"]"}},
|
||
{"name": "already-migrated", "op": "migrate", "before": {"graph-pos:board": "{}", "graph-pos:new.csv": "{\"AURORA-9001\":{\"x\":1,\"ry\":40}}"}},
|
||
{"name": "mode-linked", "op": "startup", "before": {"graph-mode": "linked"}},
|
||
{"name": "mode-all", "op": "startup", "before": {"graph-mode": "all"}}
|
||
]
|
||
```
|
||
|
||
- [ ] **Step 2: Генератор** — `web/scripts/golden-from-links.ts`:
|
||
|
||
```ts
|
||
// разовый генератор golden-эталонов доски: исполняет <script> страницы links.html ревизии 3931a88 в node:vm с заглушкой
|
||
// DOM и записывает результаты её функций на фикстурах в src/board/__fixtures__/*.expected.json.
|
||
// Запуск: cd web && npm run golden (Node 24 исполняет .ts без сборки). Эталоны коммитятся; скрипт перезапускать только
|
||
// при смене фикстур.
|
||
import { execFileSync } from "node:child_process";
|
||
import { readFileSync, writeFileSync } from "node:fs";
|
||
import vm from "node:vm";
|
||
import { canon } from "../src/test/canon.ts";
|
||
|
||
const REV = "3931a88";
|
||
const NOW = "2026-10-14T12:00:00.000Z";
|
||
const FIX = new URL("../src/board/__fixtures__/", import.meta.url);
|
||
const read = (name: string) => readFileSync(new URL(name, FIX), "utf8");
|
||
const write = (name: string, v: unknown) => writeFileSync(new URL(name, FIX), JSON.stringify(canon(v), null, 1) + "\n");
|
||
|
||
const html = execFileSync("git", ["show", `${REV}:src/jira_hub/web/links.html`], { encoding: "utf8" });
|
||
const page = html.slice(html.indexOf("<script>") + "<script>".length, html.lastIndexOf("</script>"));
|
||
|
||
// элемент-заглушка: хранит стили, атрибуты и детей; querySelector возвращает один и тот же элемент на селектор;
|
||
// высота карточки 117 px, её заголовка (.title) 38 px, остальных элементов 0
|
||
class El {
|
||
className = ""; hidden = false; disabled = false; title = ""; value = ""; textContent = ""; open = false; scrollLeft = 0;
|
||
style: Record<string, string> = {};
|
||
dataset: Record<string, string> = {};
|
||
attrs: Record<string, unknown> = {};
|
||
children: El[] = [];
|
||
sel = "";
|
||
private html = "";
|
||
private q = new Map<string, El>();
|
||
classList = { add() {}, remove() {}, toggle() {}, contains: () => false };
|
||
get innerHTML() { return this.html; }
|
||
set innerHTML(v: string) { this.html = v; this.children = []; }
|
||
get offsetHeight() { return this.sel === ".title" ? 38 : this.className.startsWith("card") ? 117 : 0; }
|
||
get offsetWidth() { return 0; }
|
||
get scrollWidth() { return 0; }
|
||
get clientWidth() { return 0; }
|
||
appendChild(c: El) { this.children.push(c); return c; }
|
||
querySelector(s: string) { let e = this.q.get(s); if (!e) { e = new El(); e.sel = s; this.q.set(s, e); } return e; }
|
||
querySelectorAll() { return []; }
|
||
setAttribute(k: string, v: unknown) { this.attrs[k] = v; }
|
||
getAttribute(k: string) { return this.attrs[k] ?? null; }
|
||
removeAttribute(k: string) { delete this.attrs[k]; }
|
||
addEventListener() {} removeEventListener() {} setPointerCapture() {} focus() {} select() {} click() {} remove() {}
|
||
contains() { return false; }
|
||
closest() { return null; }
|
||
getBoundingClientRect() { return { top: 0, left: 0, width: 0, height: 0 }; }
|
||
}
|
||
|
||
const byId = new Map<string, El>(), bySel = new Map<string, El>();
|
||
const document = {
|
||
getElementById: (id: string) => { let e = byId.get(id); if (!e) { e = new El(); byId.set(id, e); } return e; },
|
||
querySelector: (s: string) => { let e = bySel.get(s); if (!e) { e = new El(); bySel.set(s, e); } return e; },
|
||
querySelectorAll: () => [],
|
||
createElement: () => new El(),
|
||
createElementNS: () => new El(),
|
||
addEventListener() {},
|
||
documentElement: new El(),
|
||
body: new El(),
|
||
};
|
||
const data = new Map<string, string>();
|
||
const localStorage = {
|
||
getItem: (k: string) => (data.has(k) ? data.get(k)! : null),
|
||
setItem: (k: string, v: unknown) => { data.set(k, String(v)); },
|
||
removeItem: (k: string) => { data.delete(k); },
|
||
clear: () => data.clear(),
|
||
key: (i: number) => [...data.keys()][i] ?? null,
|
||
get length() { return data.size; },
|
||
};
|
||
const sandbox: Record<string, unknown> = {
|
||
document, localStorage, console, setTimeout, clearTimeout, URL, Blob, navigator: {},
|
||
requestAnimationFrame: (f: () => void) => setTimeout(f, 0),
|
||
getComputedStyle: () => ({ position: "static" }),
|
||
fetch: () => Promise.reject(new Error("нет сети")),
|
||
addEventListener() {}, removeEventListener() {},
|
||
};
|
||
const ctx = vm.createContext(sandbox);
|
||
sandbox.window = vm.runInContext("globalThis", ctx);
|
||
// фиксированное «сегодня»: findProblems, колонки дат и exportedAt зависят от new Date()
|
||
vm.runInContext(`{ const R = Date; Date = class extends R { constructor(...a) { super(...(a.length ? a : ["${NOW}"])); }
|
||
static now() { return R.parse("${NOW}"); } }; }`, ctx);
|
||
vm.runInContext(page, ctx, { filename: "links.html@" + REV });
|
||
|
||
// доступ к функциям и состоянию страницы (let/const скрипта видны следующим скриптам того же контекста)
|
||
vm.runInContext(String.raw`
|
||
function dump() { const o = {}; for (let i = 0; i < localStorage.length; i++) { const k = localStorage.key(i); o[k] = localStorage.getItem(k); } return o; }
|
||
function reset() {
|
||
localStorage.clear();
|
||
Object.assign(S, { neighbors: false, onlyLinked: false, onlyHand: false, crit: false, byDate: false, snap: true,
|
||
sprints: new Set(), who: new Set(), hideKind: new Set(), hideEdge: new Set() });
|
||
}
|
||
globalThis.__g = {
|
||
dicts: () => ({ kindLabels: KIND_LABELS, linkTypes: LINK_TYPES }),
|
||
fromHub: (s) => fromHub(s),
|
||
fromCsv: (t, n) => fromJiraCsv(t, n),
|
||
parseCsv: (t) => parseCsv(t),
|
||
fromJson: (t, n) => fromJson(t, n),
|
||
facets(model, remembered) {
|
||
reset(); MODEL = model;
|
||
if (remembered !== undefined) localStorage.setItem(fileKey("sprint"), JSON.stringify(remembered));
|
||
fillSprints(); fillWho();
|
||
return { sprintItems: sprintItems().items, whoItems: whoItems().items, sprints: S.sprints, who: S.who };
|
||
},
|
||
scenario(model, sc) {
|
||
reset(); MODEL = model;
|
||
const f = sc.filters || {};
|
||
for (const k of ["neighbors", "onlyLinked", "onlyHand", "crit", "byDate"]) S[k] = !!f[k];
|
||
for (const k of ["sprints", "who", "hideKind", "hideEdge"]) S[k] = new Set(f[k] || []);
|
||
if (sc.laneOrder) localStorage.setItem(fileKey("lanes"), JSON.stringify(sc.laneOrder));
|
||
if (sc.folded) localStorage.setItem(fileKey("fold"), JSON.stringify(sc.folded));
|
||
if (sc.saved) localStorage.setItem(fileKey("pos"), JSON.stringify(sc.saved));
|
||
if (sc.savedDate) localStorage.setItem(fileKey("posdate"), JSON.stringify(sc.savedDate));
|
||
if (sc.colWidths) localStorage.setItem(fileKey("colw"), JSON.stringify(sc.colWidths));
|
||
render();
|
||
const clamp = {};
|
||
Object.keys(cardEls).forEach((k) => { clamp[k] = cardEls[k].querySelector(".title").style.webkitLineClamp; });
|
||
return {
|
||
visible: VIS,
|
||
emptyReason: VIS.nodes.length ? null : emptyReason(),
|
||
layout: {
|
||
rel, laneOf, colBounds, baseW, pos, clamp,
|
||
lanes: lanesGeom.map((g) => ({ name: g.name, y: g.y, h: g.h, minH: g.minH, folded: g.folded })),
|
||
board: { width: board.style.width, height: board.style.height },
|
||
dateCols: dateEls.map((e) => ({ className: e.className, left: e.style.left, width: e.style.width })),
|
||
dateLabels: $("datesTrack").children.filter((e) => e.className.startsWith("dlabel"))
|
||
.map((e) => ({ className: e.className, left: e.style.left, title: e.title, innerHTML: e.innerHTML })),
|
||
edges: VIS.links.filter((l) => pos[l.src] && pos[l.dst])
|
||
.map((l) => ({ src: l.src, dst: l.dst, type: l.type, hand: l.hand, crit: l.crit, d: edgePath(pos[l.src], pos[l.dst]) })),
|
||
},
|
||
};
|
||
},
|
||
exportAfter(model, sc) { this.scenario(model, sc); return exportJson(); },
|
||
importJson(text, name) {
|
||
reset();
|
||
const r = fromJson(text, name);
|
||
applyJsonSettings(r);
|
||
return { import: r, storage: dump(), S: { neighbors: S.neighbors, onlyLinked: S.onlyLinked, onlyHand: S.onlyHand,
|
||
crit: S.crit, byDate: S.byDate, hideKind: S.hideKind, hideEdge: S.hideEdge } };
|
||
},
|
||
storage(before, op) {
|
||
localStorage.clear();
|
||
Object.entries(before).forEach(([k, v]) => localStorage.setItem(k, v));
|
||
if (op === "startup") {
|
||
const old = store.get(pref("mode"), null);
|
||
if (old !== null) { if (old === "linked") store.set(pref("neighbors"), "1"); if (old === "all") store.set(fileKey("sprint"), "[]"); store.del(pref("mode")); }
|
||
}
|
||
migrateStore();
|
||
return dump();
|
||
},
|
||
edgePath: (a, b) => edgePath(a, b),
|
||
orthoPath: (pts) => orthoPath(pts),
|
||
align(posIn, key, r, resizing) {
|
||
pos = posIn; guidesSvg.innerHTML = "";
|
||
const out = alignEdges(key, r, resizing);
|
||
return { out, guides: guidesSvg.children.map((l) => l.attrs) };
|
||
},
|
||
};`, ctx);
|
||
|
||
type G = Record<string, (...a: unknown[]) => unknown>;
|
||
const g = vm.runInContext("__g", ctx) as G;
|
||
const scenarios = JSON.parse(read("scenarios.json")) as { name: string }[];
|
||
|
||
const basic = g.fromHub(JSON.parse(read("hub-basic.board.json")));
|
||
write("hub-basic.expected.json", {
|
||
dicts: g.dicts(),
|
||
model: basic,
|
||
facets: { default: g.facets(basic), rememberedAll: g.facets(basic, []), rememberedSome: g.facets(basic, ["AWPCA_Спринт 3", "нет такого"]) },
|
||
scenarios: Object.fromEntries(scenarios.map((sc) => [sc.name, g.scenario(basic, sc)])),
|
||
export: g.exportAfter(basic, scenarios[0]),
|
||
});
|
||
const empty = g.fromHub(JSON.parse(read("hub-empty.board.json")));
|
||
write("hub-empty.expected.json", { model: empty, scenario: g.scenario(empty, scenarios[0]) });
|
||
|
||
const csv = read("jira-export.csv");
|
||
write("jira-export.expected.json", { rows: g.parseCsv(csv), model: g.fromCsv(csv, "jira-export.csv") });
|
||
write("jira-no-links.expected.json", { model: g.fromCsv(read("jira-no-links.csv"), "jira-no-links.csv") });
|
||
let bad: string | null = null;
|
||
try { g.fromCsv(read("jira-bad.csv"), "jira-bad.csv"); } catch (e) { bad = (e as Error).message; }
|
||
write("jira-bad.expected.json", { error: bad });
|
||
write("old-board.expected.json", g.importJson(read("old-board.json"), "old-board.json"));
|
||
|
||
const geo = JSON.parse(read("geometry.cases.json")) as {
|
||
edge: { a: unknown; b: unknown }[]; ortho: unknown[]; align: { pos: unknown; key: string; r: unknown; resizing: boolean }[];
|
||
};
|
||
write("geometry.expected.json", {
|
||
edge: geo.edge.map((c) => g.edgePath(c.a, c.b)),
|
||
ortho: geo.ortho.map((p) => g.orthoPath(p)),
|
||
align: geo.align.map((c) => g.align(c.pos, c.key, c.r, c.resizing)),
|
||
});
|
||
const cases = JSON.parse(read("storage.cases.json")) as { name: string; op: string; before: unknown }[];
|
||
write("storage.expected.json", Object.fromEntries(cases.map((c) => [c.name, g.storage(c.before, c.op)])));
|
||
console.log("эталоны записаны в", FIX.pathname);
|
||
```
|
||
|
||
- [ ] **Step 3: Сгенерировать и проверить эталоны**
|
||
|
||
Run: `cd web && npm run golden && git diff --stat -- src/board/__fixtures__ && npm run lint`
|
||
Expected: «эталоны записаны»; 8 файлов `*.expected.json`. Проверить руками (это и есть приёмка задачи):
|
||
- `hub-basic.expected.json` → `model.nodes` — 9 карточек (AURORA-9001…9009 без эпика 9100) и заглушка AURORA-9500 на
|
||
дорожке «Нет в зеркале»; `model.links` — 7 связей (9001→9002, 9003→9001, 9002→9005, 9004→9002, одна «связанные»
|
||
9001–9008, 9006→9500, 9007→9009; без связи с эпиком, `Duplicate`, `lower-1` и обратного дубля «связанных»);
|
||
у AURORA-9004 `epic` = «Гудвилл», у AURORA-9008 — «AURORA-9999», у AURORA-9006 — «Без эпика»;
|
||
- `scenarios.crit.visible` — `crit: true` у AURORA-9003, 9001, 9002, 9005;
|
||
- `scenarios.default.visible.problems` — непустые `dates`, `unassigned`, `late`, `hand`;
|
||
- `scenarios.empty.emptyReason` начинается с «Фильтры не оставили ни одной задачи»;
|
||
- `scenarios.by-date.layout.dateCols` непустой; `scenarios.saved-folded.layout.lanes` содержит `folded: true` у «Гудвилл»;
|
||
- `jira-bad.expected.json` → `error` — «не нашёл колонки «Ключ проблемы» и «Тема»…»;
|
||
- `storage.expected.json` → `migrate-largest` перенёс `graph-pos:board` и `graph-lanes:board` из `new.csv`.
|
||
|
||
Если какой-то пункт не выполняется — сначала поправить фикстуру (не генератор и не эталон руками) и перегенерировать.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add web/scripts/golden-from-links.ts web/src/board/__fixtures__
|
||
git commit -m "test(web): golden-эталоны доски из links.html@3931a88 на синтетических фикстурах (#21)
|
||
|
||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
### Task 6: доска — типы, константы, словари, разбор данных (`fromHub`, `fromCsv`, `fromJson`)
|
||
|
||
**Модель:** sonnet (построчный перенос с типами).
|
||
|
||
**Files:**
|
||
- Create: `web/src/board/types.ts`, `web/src/board/constants.ts`, `web/src/board/dicts.ts`,
|
||
`web/src/board/model/util.ts`, `web/src/board/model/csv.ts`, `web/src/board/model/fromHub.ts`,
|
||
`web/src/board/model/fromCsv.ts`, `web/src/board/model/fromJson.ts`, `web/src/board/model/links.ts`
|
||
- Test: `web/src/board/model/model.test.ts`, `web/src/board/dicts.test.ts`, `web/src/board/model/util.test.ts`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `HubSettings`, `BoardSnapshot` (Task 3), эталоны Task 5.
|
||
- Produces:
|
||
- `types.ts`: `BoardNode`, `Link`, `BoardModel`, `Dicts`, `Filters`, `noFilters(): Filters`, `VisNode`, `VisLink`,
|
||
`ProblemItem`, `Problems`, `Visible`, `SavedBox`, `JsonImport` (код ниже — имена полей не менять: они совпадают с
|
||
объектами старой страницы, это условие golden-сравнения).
|
||
- `dicts.ts`: `dictsFromSettings(s: HubSettings): {dicts: Dicts; missing: string[]}`.
|
||
- `model/util.ts`: `sortableDate`, `shortName`, `plural`, `fmtDate`, `dayDiff`, `notStarted`, `unassigned`, `whoKey`,
|
||
`fmtStamp`, `emptyModel(text)`, `esc` не нужен (React экранирует сам).
|
||
- `model/csv.ts`: `parseCsv(text): string[][]`.
|
||
- `model/links.ts`: `stubNode(key, lane)`, `addLink(links, seen, src, dst, type)`, `addStubs(nodes, links, lane?)`.
|
||
- `fromHub(snap: BoardSnapshot, dicts: Dicts): BoardModel`, `fromCsv(text, fileName, dicts): BoardModel`,
|
||
`fromJson(text, fileName, dicts): JsonImport`.
|
||
|
||
- [ ] **Step 1: Типы** — `web/src/board/types.ts`:
|
||
|
||
```ts
|
||
// модель доски связей; поля и их имена — как у links.html@3931a88 (условие golden-сравнения)
|
||
export interface BoardNode {
|
||
key: string; title: string; kind: string; prio: string; due: string; status: string; epic: string; sprint: string;
|
||
who: string; stub: boolean;
|
||
}
|
||
export interface Link { src: string; dst: string; type: string }
|
||
export interface BoardModel {
|
||
nodes: BoardNode[]; links: Link[]; sprints: Record<string, number>; fileName: string; problems: string[];
|
||
hasSprint: boolean; missing: number; stubLane?: string; emptyText?: string;
|
||
}
|
||
// kindLabels: метка Jira → вид работы; linkTypes: имя типа связи Jira → логический тип (обращённые словари /settings)
|
||
export interface Dicts { kindLabels: Record<string, string>; linkTypes: Record<string, string> }
|
||
|
||
export interface Filters {
|
||
neighbors: boolean; onlyLinked: boolean; onlyHand: boolean; crit: boolean; byDate: boolean;
|
||
sprints: Set<string>; who: Set<string>; hideKind: Set<string>; hideEdge: Set<string>;
|
||
}
|
||
export const noFilters = (): Filters => ({
|
||
neighbors: false, onlyLinked: false, onlyHand: false, crit: false, byDate: false,
|
||
sprints: new Set(), who: new Set(), hideKind: new Set(), hideEdge: new Set(),
|
||
});
|
||
|
||
export interface VisNode extends BoardNode { dim: boolean; focus: boolean; crit: boolean }
|
||
export interface VisLink extends Link { hand: boolean; crit: boolean }
|
||
export interface ProblemItem { a: VisNode; b: VisNode; text: string }
|
||
export interface Problems { dates: ProblemItem[]; unassigned: ProblemItem[]; late: ProblemItem[]; hand: ProblemItem[] }
|
||
export interface Visible {
|
||
nodes: VisNode[]; links: VisLink[]; lanes: { name: string; keys: string[] }[];
|
||
removed: { core: number; ctx: number; edge: number; loose: number }; problems: Problems;
|
||
}
|
||
export interface SavedBox { x: number; ry: number; w: number; h?: number }
|
||
export interface JsonImport {
|
||
model: BoardModel; layout: Record<string, SavedBox>; layoutByDate: Record<string, SavedBox>;
|
||
dateColumns: Record<string, number> | null; laneOrder: string[]; collapsedLanes: string[] | null;
|
||
filters: Record<string, unknown>;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Константы** — `web/src/board/constants.ts`: перенести строки 309–330 ревизии 3931a88 со следующими
|
||
изменениями: `JIRA` → `export const JIRA_URL`; `EPICS`, `KIND`, `PRIO`, `RELATED`, `NO_EPIC`, `NOT_IN_FILE`,
|
||
`NOT_IN_HUB`, `STUB_TEXT`, `KEY_RE`, геометрия (`CW … TITLE_LH`) — `export const` с типами
|
||
(`Record<string, string>` и т. п.); **не переносить** `KIND_LABELS`, `LINK_TYPES` (приходят из `/settings`),
|
||
`TOKEN_KEY`, `POLL_MS` (есть в `auth/token.ts` и Task 10), `NS`, `ARROWS` (разметка SVG — в компонентах). Добавить
|
||
`export const CPAD = 6;` (строка 403) и `export const EDGE_R = 10;` (строка 818). Комментарии строк переносятся.
|
||
|
||
- [ ] **Step 3: Падающие тесты**
|
||
|
||
`web/src/board/dicts.test.ts`:
|
||
|
||
```ts
|
||
import { describe, expect, it } from "vitest";
|
||
import golden from "./__fixtures__/hub-basic.expected.json";
|
||
import settings from "./__fixtures__/settings.json";
|
||
import { dictsFromSettings } from "./dicts.ts";
|
||
|
||
describe("dictsFromSettings", () => {
|
||
it("обращает словари настроек и совпадает со словарями старой страницы", () => {
|
||
const { dicts, missing } = dictsFromSettings(settings);
|
||
expect(dicts).toEqual(golden.dicts);
|
||
expect(missing).toEqual([]);
|
||
});
|
||
it("называет пустые и отсутствующие словари", () => {
|
||
expect(dictsFromSettings({ kind_labels: {} }).missing).toEqual(["kind_labels", "link_types"]);
|
||
});
|
||
});
|
||
```
|
||
|
||
`web/src/board/model/model.test.ts`:
|
||
|
||
```ts
|
||
import { readFileSync } from "node:fs";
|
||
import { describe, expect, it } from "vitest";
|
||
import type { BoardSnapshot } from "../../api/types.ts";
|
||
import { canon } from "../../test/canon.ts";
|
||
import { dictsFromSettings } from "../dicts.ts";
|
||
import settings from "../__fixtures__/settings.json";
|
||
import { parseCsv } from "./csv.ts";
|
||
import { fromCsv } from "./fromCsv.ts";
|
||
import { fromHub } from "./fromHub.ts";
|
||
import { fromJson } from "./fromJson.ts";
|
||
|
||
const fx = (name: string) => readFileSync(new URL(`../__fixtures__/${name}`, import.meta.url), "utf8");
|
||
const golden = (name: string) => JSON.parse(fx(name));
|
||
const { dicts } = dictsFromSettings(settings);
|
||
|
||
describe("модель доски = links.html@3931a88", () => {
|
||
it("fromHub — снимок с эпиками, соседями, заглушками", () => {
|
||
const snap = JSON.parse(fx("hub-basic.board.json")) as BoardSnapshot;
|
||
expect(canon(fromHub(snap, dicts))).toEqual(golden("hub-basic.expected.json").model);
|
||
});
|
||
it("fromHub — пустое зеркало без полного прогона", () => {
|
||
const snap = JSON.parse(fx("hub-empty.board.json")) as BoardSnapshot;
|
||
expect(canon(fromHub(snap, dicts))).toEqual(golden("hub-empty.expected.json").model);
|
||
});
|
||
it("parseCsv и fromCsv — BOM, CRLF, кавычки, повторные колонки, даты разных локалей", () => {
|
||
const text = fx("jira-export.csv");
|
||
const g = golden("jira-export.expected.json");
|
||
expect(parseCsv(text)).toEqual(g.rows);
|
||
expect(canon(fromCsv(text, "jira-export.csv", dicts))).toEqual(g.model);
|
||
});
|
||
it("fromCsv без колонок связей, спринта и эпика — проблемы файла", () => {
|
||
expect(canon(fromCsv(fx("jira-no-links.csv"), "jira-no-links.csv", dicts))).toEqual(golden("jira-no-links.expected.json").model);
|
||
});
|
||
it("fromCsv без ключа и темы — та же ошибка", () => {
|
||
expect(() => fromCsv(fx("jira-bad.csv"), "jira-bad.csv", dicts)).toThrow(golden("jira-bad.expected.json").error);
|
||
});
|
||
it("fromJson — выгрузка старой страницы", () => {
|
||
expect(canon(fromJson(fx("old-board.json"), "old-board.json", dicts))).toEqual(golden("old-board.expected.json").import);
|
||
});
|
||
it("fromJson — не JSON и JSON без nodes", () => {
|
||
expect(() => fromJson("{", "x.json", dicts)).toThrow("это не JSON");
|
||
expect(() => fromJson("{}", "x.json", dicts)).toThrow("в JSON нет списка nodes");
|
||
});
|
||
});
|
||
```
|
||
|
||
`web/src/board/model/util.test.ts`:
|
||
|
||
```ts
|
||
import { describe, expect, it } from "vitest";
|
||
import { dayDiff, fmtDate, plural, shortName, sortableDate } from "./util.ts";
|
||
|
||
describe("util", () => {
|
||
it("sortableDate приводит даты Jira к ГГГГ-ММ-ДД", () => {
|
||
expect(sortableDate("2026-10-20T10:00:00")).toBe("2026-10-20");
|
||
expect(sortableDate("5.1.2026")).toBe("2026-01-05");
|
||
expect(sortableDate("20/окт/26")).toBe("2026-10-20");
|
||
expect(sortableDate("10/Oct/2026")).toBe("2026-10-10");
|
||
expect(sortableDate("потом")).toBe("потом");
|
||
});
|
||
it("shortName, plural, fmtDate, dayDiff", () => {
|
||
expect(shortName("Степанов Максим Александрович")).toBe("Степанов М. А.");
|
||
expect(shortName("Stepanov-MAl")).toBe("Stepanov-MAl");
|
||
expect(shortName("")).toBe("не назначен");
|
||
expect(plural(21, "задача", "задачи", "задач")).toBe("21 задача");
|
||
expect(plural(12, "задача", "задачи", "задач")).toBe("12 задач");
|
||
expect(fmtDate("2026-10-20")).toBe("20.10");
|
||
expect(dayDiff("2026-10-14", "2026-10-20")).toBe(6);
|
||
expect(dayDiff("", "2026-10-20")).toBeNull();
|
||
});
|
||
});
|
||
```
|
||
|
||
Run: `cd web && npx vitest run src/board`
|
||
Expected: FAIL — модулей нет.
|
||
|
||
- [ ] **Step 4: Перенос** (ревизия 3931a88; правила: глобальные `KIND_LABELS`/`LINK_TYPES` → `dicts.kindLabels`/
|
||
`dicts.linkTypes`; `throw new Error(…)` и тексты — без изменений; `const`-стрелочные функции страницы → `export function`
|
||
с типами параметров):
|
||
- `model/util.ts` ← строки 336–365 (`plural`, `fmtDate`, `dayDiff`, `notStarted`, `unassigned`, `whoKey`, `shortName`,
|
||
`sortableDate`), строка 1030 (`fmtStamp`), строка 1031 (`emptyModel(text: string): BoardModel`); `esc` и `$` не
|
||
переносить.
|
||
- `model/csv.ts` ← строки 411–424 (`parseCsv`).
|
||
- `model/links.ts` ← строки 425–437 (`stubNode`, `addLink(links: Link[], seen: Set<string>, …)`, `addStubs`).
|
||
- `model/fromCsv.ts` ← строки 442–482 (`fromJiraCsv` → `fromCsv(text, fileName, dicts)`; в строке 456 перебор
|
||
`Object.entries(dicts.linkTypes)`, в строке 469 — `dicts.kindLabels[v]`).
|
||
- `model/fromJson.ts` ← строки 484–508 (`fromJson(text, fileName, dicts)`; строка 499 —
|
||
`new Set(Object.values(dicts.linkTypes))`; `strList` — из `storage.ts` ещё нет, поэтому объявить локально
|
||
`const strList = (v: unknown): string[] => Array.isArray(v) ? v.filter((s): s is string => typeof s === "string") : [];`
|
||
— в Task 9 этот же помощник экспортируется из `storage.ts`, а здесь импортируется оттуда).
|
||
- `model/fromHub.ts` ← строки 513–533 (`fromHub(snap, dicts)`; строки 520–522 — `dicts.kindLabels`, строка 527 —
|
||
`dicts.linkTypes[l.link_type]`).
|
||
|
||
`web/src/board/dicts.ts`:
|
||
|
||
```ts
|
||
import type { HubSettings } from "../api/types.ts";
|
||
import type { Dicts } from "./types.ts";
|
||
|
||
// словари доски из настроек хаба: kind_labels (вид → метка) и link_types (логический тип → имя Jira) обращаются
|
||
// в «метка → вид» и «имя Jira → логический тип»; пустой словарь — в missing (доска сообщит, чего не хватает)
|
||
const invert = (o: Record<string, string> | undefined) => Object.fromEntries(Object.entries(o ?? {}).map(([k, v]) => [v, k]));
|
||
|
||
export function dictsFromSettings(s: HubSettings): { dicts: Dicts; missing: string[] } {
|
||
const dicts = { kindLabels: invert(s.kind_labels), linkTypes: invert(s.link_types) };
|
||
const missing = [["kind_labels", dicts.kindLabels], ["link_types", dicts.linkTypes]]
|
||
.filter(([, d]) => Object.keys(d as object).length === 0).map(([n]) => n as string);
|
||
return { dicts, missing };
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Прогнать**
|
||
|
||
Run: `cd web && npm run lint && npx vitest run src/board`
|
||
Expected: PASS — 11 тестов. Расхождение с эталоном — ошибка переноса: сравнить построчно с ревизией 3931a88, эталон не править.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add web/src/board
|
||
git commit -m "feat(web): модель доски — разбор снимка хаба, CSV и JSON, словари из настроек (#21)
|
||
|
||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 7: доска — выборка, критический путь, проблемы, счётчики фильтров
|
||
|
||
**Модель:** sonnet.
|
||
|
||
**Files:**
|
||
- Create: `web/src/board/analysis/visible.ts`, `web/src/board/analysis/critical.ts`,
|
||
`web/src/board/analysis/problems.ts`, `web/src/board/analysis/facets.ts`, `web/src/test/boardScenarios.ts`
|
||
- Test: `web/src/board/analysis/analysis.test.ts`
|
||
|
||
**Interfaces:**
|
||
- Consumes: типы и модель Task 6; эталоны Task 5.
|
||
- Produces:
|
||
- `passes(x: BoardNode, f: Filters, skip?: "sprint" | "who" | "kind"): boolean`, `edgeClass(l: Link): "rel" | "block"`;
|
||
- `computeVisible(model: BoardModel, f: Filters, laneOrder: string[], today: string): Visible`;
|
||
- `criticalPath(nodes: VisNode[], links: VisLink[]): {nodes: Set<string>; edges: Set<string>}`;
|
||
- `findProblems(nodes: VisNode[], links: VisLink[], today: string): Problems`;
|
||
- `emptyReason(model: BoardModel, vis: Visible, f: Filters): string`;
|
||
- `interface FacetItem {value: string; label: string; title: string; count: string; cls: string}`;
|
||
`sprintItems(model, f): {total: Record<string, number>; items: FacetItem[]}`, `whoItems(model, f)` — то же;
|
||
- `initialSprints(model: BoardModel, f: Filters, remembered: unknown): Set<string>`,
|
||
`initialWho(model: BoardModel, f: Filters, remembered: unknown): Set<string>`;
|
||
- `whoBox(model, f): {kind: "none"} | {kind: "text"; text: string} | {kind: "multi"}` и
|
||
`sprintBox(model): {kind: "none"} | {kind: "text"; text: string} | {kind: "multi"}` — что показывать вместо
|
||
выпадающего списка (строки 953–954, 967–969).
|
||
|
||
- [ ] **Step 1: Помощник сценариев** — `web/src/test/boardScenarios.ts` (общий для тестов анализа и раскладки; в
|
||
тестовый файл не кладётся, иначе импорт запустит чужие тесты):
|
||
|
||
```ts
|
||
import { noFilters, type Filters } from "../board/types.ts";
|
||
|
||
// фильтры сценария из __fixtures__/scenarios.json: отсутствующие флаги — false, множества — пустые
|
||
export function filtersOf(sc: { filters: Record<string, unknown> }): Filters {
|
||
const f = noFilters(), src = sc.filters;
|
||
for (const k of ["neighbors", "onlyLinked", "onlyHand", "crit", "byDate"] as const) f[k] = !!src[k];
|
||
for (const k of ["sprints", "who", "hideKind", "hideEdge"] as const) f[k] = new Set((src[k] as string[] | undefined) ?? []);
|
||
return f;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Падающие тесты** — `web/src/board/analysis/analysis.test.ts`:
|
||
|
||
```ts
|
||
import { readFileSync } from "node:fs";
|
||
import { describe, expect, it } from "vitest";
|
||
import type { BoardSnapshot } from "../../api/types.ts";
|
||
import { canon } from "../../test/canon.ts";
|
||
import { dictsFromSettings } from "../dicts.ts";
|
||
import settings from "../__fixtures__/settings.json";
|
||
import scenarios from "../__fixtures__/scenarios.json";
|
||
import { fromHub } from "../model/fromHub.ts";
|
||
import { filtersOf } from "../../test/boardScenarios.ts";
|
||
import { noFilters } from "../types.ts";
|
||
import { initialSprints, initialWho, sprintItems, whoItems } from "./facets.ts";
|
||
import { emptyReason, computeVisible } from "./visible.ts";
|
||
|
||
const fx = (name: string) => readFileSync(new URL(`../__fixtures__/${name}`, import.meta.url), "utf8");
|
||
const golden = JSON.parse(fx("hub-basic.expected.json"));
|
||
const { dicts } = dictsFromSettings(settings);
|
||
const model = fromHub(JSON.parse(fx("hub-basic.board.json")) as BoardSnapshot, dicts);
|
||
const TODAY = "2026-10-14";
|
||
|
||
type Sc = { name: string; filters: Record<string, unknown>; laneOrder?: string[] };
|
||
|
||
describe("выборка = links.html@3931a88", () => {
|
||
for (const sc of scenarios as Sc[]) {
|
||
it(`computeVisible: ${sc.name}`, () => {
|
||
const f = filtersOf(sc);
|
||
const vis = computeVisible(model, f, sc.laneOrder ?? [], TODAY);
|
||
const g = golden.scenarios[sc.name];
|
||
expect(canon(vis)).toEqual(g.visible);
|
||
expect(vis.nodes.length ? null : emptyReason(model, vis, f)).toEqual(g.emptyReason);
|
||
});
|
||
}
|
||
|
||
it("пустое зеркало — своя причина", () => {
|
||
const empty = fromHub(JSON.parse(fx("hub-empty.board.json")) as BoardSnapshot, dicts);
|
||
const vis = computeVisible(empty, noFilters(), [], TODAY);
|
||
expect(emptyReason(empty, vis, noFilters())).toBe(JSON.parse(fx("hub-empty.expected.json")).scenario.emptyReason);
|
||
});
|
||
|
||
it("счётчики и начальный выбор спринтов и исполнителей", () => {
|
||
const f = noFilters();
|
||
const check = (remembered: unknown, g: { sprintItems: unknown; whoItems: unknown; sprints: unknown; who: unknown }) => {
|
||
expect(canon(sprintItems(model, f).items)).toEqual(g.sprintItems);
|
||
expect(canon(whoItems(model, f).items)).toEqual(g.whoItems);
|
||
expect(canon(initialSprints(model, f, remembered))).toEqual(g.sprints);
|
||
expect(canon(initialWho(model, f, []))).toEqual(g.who);
|
||
};
|
||
check(null, golden.facets.default);
|
||
check([], golden.facets.rememberedAll);
|
||
check(["AWPCA_Спринт 3", "нет такого"], golden.facets.rememberedSome);
|
||
});
|
||
});
|
||
```
|
||
|
||
Run: `cd web && npx vitest run src/board/analysis` → FAIL (модулей нет).
|
||
|
||
- [ ] **Step 3: Перенос** (глобальные `S`, `MODEL`, `laneOrder()`, `new Date()` → параметры `f`, `model`, `laneOrder`,
|
||
`today`):
|
||
- `analysis/visible.ts` ← строки 538–572 (`passes`, `edgeClass`, `computeVisible`) и 608–617 (`emptyReason`,
|
||
`VIS.removed` → `vis.removed`); `computeVisible` вызывает `criticalPath` и `findProblems(nodes, links, today)`.
|
||
- `analysis/critical.ts` ← строки 574–592.
|
||
- `analysis/problems.ts` ← строки 594–607; `today` — параметр вместо `new Date().toISOString().slice(0, 10)`.
|
||
- `analysis/facets.ts` ← строки 941–949 (`facetItems`, `sprintItems`, `whoItems` с параметрами `model`, `f`),
|
||
строки 955–961 (`initialSprints`: пустая модель или `!hasSprint` → пустое множество; иначе логика строк 955–961 с
|
||
`remembered` вместо `store.json(fileKey("sprint"), null)`), строка 970 (`initialWho`: `remembered` вместо
|
||
`store.json(fileKey("who"), [])`, пустая модель → пустое множество), строки 953–954 и 967–969 (`sprintBox`, `whoBox`
|
||
— тексты «колонки «Спринт» нет — показаны все задачи», «все задачи без исполнителя», «колонки «Исполнитель» нет»).
|
||
|
||
- [ ] **Step 4: Прогнать**
|
||
|
||
Run: `cd web && npm run lint && npx vitest run src/board`
|
||
Expected: PASS — 12 сценариев + 2 теста анализа + тесты Task 6.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add web/src/board/analysis web/src/test/boardScenarios.ts
|
||
git commit -m "feat(web): выборка доски, критический путь, проблемы, счётчики фильтров (#21)
|
||
|
||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 8: доска — раскладка, стрелки, примагничивание
|
||
|
||
**Модель:** sonnet.
|
||
|
||
**Files:**
|
||
- Create: `web/src/board/layout/layout.ts`, `web/src/board/layout/edges.ts`, `web/src/board/layout/align.ts`
|
||
- Test: `web/src/board/layout/layout.test.ts`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `Visible`, `VisNode`, `SavedBox`, константы (Task 6–7).
|
||
- Produces:
|
||
|
||
```ts
|
||
export interface Rect { x: number; y: number; w: number; h: number }
|
||
export interface RelBox { x: number; ry: number; w: number; h: number; auto: boolean; other?: number }
|
||
export interface DateCol { date: string; x0: number; w: number; count: number; cls: "" | "past" | "today" | "none" }
|
||
export interface Layout {
|
||
rel: Record<string, RelBox>; // раскладка карточек относительно дорожек (начальная; правки — у вызывающего)
|
||
laneOf: Record<string, number>; // индекс дорожки карточки
|
||
colBounds: Record<string, { x0: number; x1: number }>; // колонка даты карточки (только «по датам»)
|
||
baseW: number; // ширина доски по слоям
|
||
laneNames: string[];
|
||
dateCols: DateCol[] | null; // колонки дат («по датам»), иначе null
|
||
multiSprint: boolean; // на доске больше одного спринта — карточкам нужна метка спринта (строка 661)
|
||
}
|
||
export interface LaneGeom { name: string; y: number; h: number; minH: number; folded: boolean }
|
||
export interface Placed { lanes: LaneGeom[]; pos: Record<string, Rect>; width: number; height: number }
|
||
|
||
export function measuredHeight(offsetHeight: number): number; // строка 675
|
||
export function computeLayout(vis: Visible, o: {
|
||
byDate: boolean; colWidths: Record<string, number>; saved: unknown; today: string;
|
||
measure: (key: string) => { h: number; other: number }; // h — уже measuredHeight(offsetHeight) при ширине CW
|
||
}): Layout;
|
||
export function place(layout: Layout, rel: Record<string, RelBox>, folded: Set<string>): Placed; // relayout
|
||
export function clampToCol(colBounds: Layout["colBounds"], key: string, x: number, w: number): { x: number; w: number };
|
||
export function lineClamp(r: RelBox): string; // "unset" или число строк заголовка строкой (строка 800)
|
||
export function dateTitle(c: DateCol): string; // подсказка подписи колонки (строка 686)
|
||
export function dateText(c: DateCol): string; // «20.10» или «без срока» (строка 687)
|
||
// edges.ts
|
||
export function orthoPath(pts: { x: number; y: number }[]): string;
|
||
export function edgePath(a: Rect, b: Rect): string;
|
||
// align.ts
|
||
export function alignEdges(pos: Record<string, Rect>, key: string, r: Rect, resizing: boolean):
|
||
{ x: number; y: number; w: number; h: number; snappedX: boolean; snappedY: boolean; guideX: number | null; guideY: number | null };
|
||
```
|
||
|
||
- [ ] **Step 1: Падающие тесты** — `web/src/board/layout/layout.test.ts`:
|
||
|
||
```ts
|
||
import { readFileSync } from "node:fs";
|
||
import { describe, expect, it } from "vitest";
|
||
import type { BoardSnapshot } from "../../api/types.ts";
|
||
import { canon } from "../../test/canon.ts";
|
||
import { computeVisible } from "../analysis/visible.ts";
|
||
import { filtersOf } from "../../test/boardScenarios.ts";
|
||
import { dictsFromSettings } from "../dicts.ts";
|
||
import settings from "../__fixtures__/settings.json";
|
||
import scenarios from "../__fixtures__/scenarios.json";
|
||
import { fromHub } from "../model/fromHub.ts";
|
||
import { alignEdges } from "./align.ts";
|
||
import { edgePath, orthoPath } from "./edges.ts";
|
||
import { computeLayout, dateText, dateTitle, lineClamp, measuredHeight, place, type Rect } from "./layout.ts";
|
||
|
||
const fx = (name: string) => readFileSync(new URL(`../__fixtures__/${name}`, import.meta.url), "utf8");
|
||
const golden = JSON.parse(fx("hub-basic.expected.json"));
|
||
const geo = JSON.parse(fx("geometry.cases.json"));
|
||
const geoG = JSON.parse(fx("geometry.expected.json"));
|
||
const { dicts } = dictsFromSettings(settings);
|
||
const model = fromHub(JSON.parse(fx("hub-basic.board.json")) as BoardSnapshot, dicts);
|
||
const TODAY = "2026-10-14";
|
||
const measure = () => ({ h: measuredHeight(117), other: 117 - 38 }); // как заглушка DOM генератора эталонов
|
||
|
||
type Sc = { name: string; filters: Record<string, unknown>; laneOrder?: string[]; folded?: string[];
|
||
saved?: unknown; savedDate?: unknown; colWidths?: Record<string, number> };
|
||
|
||
describe("раскладка = links.html@3931a88", () => {
|
||
for (const sc of scenarios as Sc[]) {
|
||
it(`computeLayout + place: ${sc.name}`, () => {
|
||
const f = filtersOf(sc);
|
||
const vis = computeVisible(model, f, sc.laneOrder ?? [], TODAY);
|
||
const g = golden.scenarios[sc.name].layout;
|
||
if (!vis.nodes.length) { expect(g.lanes).toEqual([]); return; }
|
||
const lay = computeLayout(vis, { byDate: f.byDate, colWidths: sc.colWidths ?? {},
|
||
saved: f.byDate ? sc.savedDate ?? null : sc.saved ?? null, today: TODAY, measure });
|
||
const placed = place(lay, lay.rel, new Set(sc.folded ?? []));
|
||
expect(canon(lay.rel)).toEqual(g.rel);
|
||
expect(canon(lay.laneOf)).toEqual(g.laneOf);
|
||
expect(canon(lay.colBounds)).toEqual(g.colBounds);
|
||
expect(lay.baseW).toBe(g.baseW);
|
||
expect(canon(placed.pos)).toEqual(g.pos);
|
||
expect(canon(placed.lanes)).toEqual(g.lanes);
|
||
expect({ width: placed.width + "px", height: placed.height + "px" }).toEqual(g.board);
|
||
expect(Object.fromEntries(Object.keys(lay.rel).map((k) => [k, placed.pos[k] ? lineClamp(lay.rel[k]) : "unset"])))
|
||
.toEqual(g.clamp);
|
||
const cls = (c: { cls: string }) => (c.cls ? " " + c.cls : "");
|
||
expect((lay.dateCols ?? []).map((c) => ({ className: "dcol" + cls(c), left: c.x0 + "px", width: c.w + "px" })))
|
||
.toEqual(g.dateCols);
|
||
expect((lay.dateCols ?? []).map((c) => ({ className: "dlabel" + cls(c), left: c.x0 + 8 + "px", title: dateTitle(c),
|
||
innerHTML: `${dateText(c)}<span class="n">${c.count}</span>` }))).toEqual(g.dateLabels);
|
||
const edges = vis.links.filter((l) => placed.pos[l.src] && placed.pos[l.dst])
|
||
.map((l) => ({ src: l.src, dst: l.dst, type: l.type, hand: l.hand, crit: l.crit, d: edgePath(placed.pos[l.src], placed.pos[l.dst]) }));
|
||
expect(canon(edges)).toEqual(g.edges);
|
||
});
|
||
}
|
||
});
|
||
|
||
describe("геометрия = links.html@3931a88", () => {
|
||
it("edgePath и orthoPath", () => {
|
||
expect(geo.edge.map((c: { a: Rect; b: Rect }) => edgePath(c.a, c.b))).toEqual(geoG.edge);
|
||
expect(geo.ortho.map((p: { x: number; y: number }[]) => orthoPath(p))).toEqual(geoG.ortho);
|
||
});
|
||
it("alignEdges и направляющие", () => {
|
||
geo.align.forEach((c: { pos: Record<string, Rect>; key: string; r: Rect; resizing: boolean }, i: number) => {
|
||
const { guideX, guideY, ...out } = alignEdges(c.pos, c.key, c.r, c.resizing);
|
||
expect(out).toEqual(geoG.align[i].out);
|
||
const guides = [...(guideX === null ? [] : [{ x1: guideX, y1: 0, x2: guideX, y2: 0 }]),
|
||
...(guideY === null ? [] : [{ x1: 0, y1: guideY, x2: 0, y2: guideY }])];
|
||
expect(guides).toEqual(geoG.align[i].guides);
|
||
});
|
||
});
|
||
});
|
||
```
|
||
|
||
Высота и ширина доски в заглушке DOM равны 0, поэтому эталонные направляющие идут от 0 до 0; в компоненте вместо 0
|
||
подставляются размеры доски.
|
||
|
||
Run: `cd web && npx vitest run src/board/layout` → FAIL.
|
||
|
||
- [ ] **Step 2: Перенос**
|
||
- `layout/layout.ts` ← `render()` строки 635–672 без DOM: `layer`, `dateCols`, `layerCols`, `colW`, `layerX`, `baseW`,
|
||
стопки (`stacks`), `laneOf`, начальные `rel`; вместо `renderLane` — `laneNames`; вместо `renderDateCols` — `dateCols`
|
||
с `x0 = layerX[c] - LEFT / 2`, `w = colW(c)`, `count`, `cls` (строки 679–682, `today` — параметр); `colBounds`
|
||
(строка 660); `multiSprint` (строка 661); измерение (строка 664) — `measure(key)` даёт `h` и `other`; стопки
|
||
(строка 665); сохранённая раскладка (строки 666–672) — из параметра `saved`. `measuredHeight` ← строка 675.
|
||
`place` ← `relayout()` строки 785–806 без DOM: возвращает `lanes`, `pos`, `width = max(baseW, …)`, `height = yy`.
|
||
`clampToCol` ← строки 403–408 (`colBounds` — параметр). `lineClamp` ← выражение строки 800.
|
||
`dateTitle`/`dateText` ← строки 686–687.
|
||
- `layout/edges.ts` ← строки 818–837.
|
||
- `layout/align.ts` ← строки 851–865 без отрисовки линий: `guideX = bx ? bx.t : null`, `guideY = by ? by.t : null`.
|
||
|
||
- [ ] **Step 3: Прогнать**
|
||
|
||
Run: `cd web && npm run lint && npx vitest run src/board`
|
||
Expected: PASS — 12 сценариев раскладки + 2 теста геометрии + прежние.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add web/src/board/layout
|
||
git commit -m "feat(web): раскладка доски, стрелки и примагничивание — без DOM (#21)
|
||
|
||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 9: доска — хранилище настроек, импорт и экспорт JSON
|
||
|
||
**Модель:** sonnet.
|
||
|
||
**Files:**
|
||
- Create: `web/src/board/storage.ts`, `web/src/board/exportJson.ts`
|
||
- Modify: `web/src/board/model/fromJson.ts` (локальный `strList` → импорт из `storage.ts`)
|
||
- Test: `web/src/board/storage.test.ts`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `Filters`, `JsonImport`, `BoardModel`, `Visible`, `RelBox`, `Layout["colBounds"]`.
|
||
- Produces:
|
||
|
||
```ts
|
||
export const store: { get(k: string, d: string | null): string | null; set(k: string, v: string): void; del(k: string): void;
|
||
json<T>(k: string, d: T): T };
|
||
export const pref: (name: string) => string; // "graph-" + name
|
||
export const fileKey: (name: string) => string; // "graph-" + name + ":board"
|
||
export const strList: (v: unknown) => string[];
|
||
export function migrateMode(): void; // строка 1203
|
||
export function migrateStore(): void; // строки 381–389
|
||
export function laneOrder(): string[];
|
||
export function foldedSet(): Set<string>;
|
||
export function colWidths(): Record<string, number>;
|
||
export function savedLayout(byDate: boolean): unknown; // store.json(fileKey(byDate ? "posdate" : "pos"), null)
|
||
export function saveLayout(rel: Record<string, RelBox>, colBounds: Layout["colBounds"], byDate: boolean): void;
|
||
export interface BoardPrefs { filters: Filters; snap: boolean; table: boolean; problems: boolean }
|
||
export function readPrefs(): BoardPrefs; // строки 1204–1212 (sprints/who пустые: их выбирает initialSprints/initialWho)
|
||
export type FlagName = "neighbors" | "onlyLinked" | "onlyHand" | "crit" | "byDate";
|
||
export function writeFlag(name: FlagName | "snap" | "table" | "problems", on: boolean): void;
|
||
export function writeHidden(hideKind: Set<string>, hideEdge: Set<string>): void;
|
||
export function rememberedSprints(): unknown; // store.json(fileKey("sprint"), null)
|
||
export function rememberedWho(): unknown; // store.json(fileKey("who"), [])
|
||
export function writeSprints(s: Set<string>): void;
|
||
export function writeWho(s: Set<string>): void;
|
||
export function toggleLane(name: string): void; // строка 807 (хранилище)
|
||
export function moveLane(current: string[], name: string, to: number): boolean; // строки 808–814 (хранилище), true — порядок изменён
|
||
export function setColWidth(date: string, w: number | null): void; // строки 691 и 699: null или w ≤ COLW — удалить
|
||
export function resetLayout(byDate: boolean): void; // строка 1012
|
||
export function applyJsonSettings(r: JsonImport): Partial<Filters>; // строки 1130–1149: пишет хранилище, возвращает изменённые фильтры
|
||
// exportJson.ts
|
||
export function exportJson(model: BoardModel, vis: Visible, f: Filters, now: string): unknown; // строки 1172–1184 (без saveLayout — его вызывает компонент перед экспортом)
|
||
export function exportFileName(model: BoardModel, now: string): string; // строка 1191
|
||
```
|
||
|
||
Ключи флагов в хранилище — как у страницы: `neighbors` → `graph-neighbors`, `onlyLinked` → `graph-onlylinked`,
|
||
`onlyHand` → `graph-onlyhand`, `crit` → `graph-crit`, `byDate` → `graph-bydate`, `snap` → `graph-snap`
|
||
(по умолчанию `true`), `table` → `graph-tbl`, `problems` → `graph-prob`; скрытые — `graph-hidekind`, `graph-hideedge`.
|
||
|
||
- [ ] **Step 1: Падающие тесты** — `web/src/board/storage.test.ts`:
|
||
|
||
```ts
|
||
import { readFileSync } from "node:fs";
|
||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||
import type { BoardSnapshot } from "../api/types.ts";
|
||
import { canon } from "../test/canon.ts";
|
||
import { computeVisible } from "./analysis/visible.ts";
|
||
import { dictsFromSettings } from "./dicts.ts";
|
||
import { exportJson } from "./exportJson.ts";
|
||
import settings from "./__fixtures__/settings.json";
|
||
import storageCases from "./__fixtures__/storage.cases.json";
|
||
import { computeLayout, measuredHeight } from "./layout/layout.ts";
|
||
import { fromHub } from "./model/fromHub.ts";
|
||
import { fromJson } from "./model/fromJson.ts";
|
||
import { applyJsonSettings, migrateMode, migrateStore, readPrefs, saveLayout, setColWidth, writeFlag } from "./storage.ts";
|
||
import { noFilters } from "./types.ts";
|
||
|
||
const fx = (name: string) => readFileSync(new URL(`./__fixtures__/${name}`, import.meta.url), "utf8");
|
||
const { dicts } = dictsFromSettings(settings);
|
||
const NOW = "2026-10-14T12:00:00.000Z";
|
||
const dump = () => Object.fromEntries(Object.keys(localStorage).map((k) => [k, localStorage.getItem(k)]));
|
||
|
||
describe("хранилище = links.html@3931a88", () => {
|
||
beforeEach(() => localStorage.clear());
|
||
|
||
const expected = JSON.parse(fx("storage.expected.json"));
|
||
for (const c of storageCases as { name: string; op: string; before: Record<string, string> }[]) {
|
||
it(`миграция: ${c.name}`, () => {
|
||
Object.entries(c.before).forEach(([k, v]) => localStorage.setItem(k, v));
|
||
if (c.op === "startup") migrateMode();
|
||
migrateStore();
|
||
expect(dump()).toEqual(expected[c.name]);
|
||
});
|
||
}
|
||
|
||
it("импорт JSON старой страницы: хранилище и фильтры", () => {
|
||
const g = JSON.parse(fx("old-board.expected.json"));
|
||
const patch = applyJsonSettings(fromJson(fx("old-board.json"), "old-board.json", dicts));
|
||
expect(dump()).toEqual(g.storage);
|
||
const f = { ...noFilters(), ...patch };
|
||
expect(canon({ neighbors: f.neighbors, onlyLinked: f.onlyLinked, onlyHand: f.onlyHand, crit: f.crit,
|
||
byDate: f.byDate, hideKind: f.hideKind, hideEdge: f.hideEdge })).toEqual(g.S);
|
||
});
|
||
|
||
it("экспорт JSON после раскладки по умолчанию", () => {
|
||
const g = JSON.parse(fx("hub-basic.expected.json"));
|
||
const model = fromHub(JSON.parse(fx("hub-basic.board.json")) as BoardSnapshot, dicts);
|
||
const vis = computeVisible(model, noFilters(), [], "2026-10-14");
|
||
const lay = computeLayout(vis, { byDate: false, colWidths: {}, saved: null, today: "2026-10-14",
|
||
measure: () => ({ h: measuredHeight(117), other: 79 }) });
|
||
saveLayout(lay.rel, lay.colBounds, false);
|
||
expect(canon(exportJson(model, vis, noFilters(), NOW))).toEqual(g.export);
|
||
});
|
||
|
||
it("флаги и ширины колонок читаются обратно", () => {
|
||
writeFlag("neighbors", true);
|
||
writeFlag("snap", false);
|
||
expect(readPrefs().filters.neighbors).toBe(true);
|
||
expect(readPrefs().snap).toBe(false);
|
||
setColWidth("2026-10-20", 400);
|
||
expect(JSON.parse(localStorage.getItem("graph-colw:board")!)).toEqual({ "2026-10-20": 400 });
|
||
setColWidth("2026-10-20", null);
|
||
expect(JSON.parse(localStorage.getItem("graph-colw:board")!)).toEqual({});
|
||
});
|
||
|
||
it("закрытое хранилище: чтение даёт умолчания, запись молча пропускается", () => {
|
||
vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => { throw new DOMException("closed", "SecurityError"); });
|
||
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { throw new DOMException("closed", "SecurityError"); });
|
||
vi.spyOn(Storage.prototype, "key").mockImplementation(() => { throw new DOMException("closed", "SecurityError"); });
|
||
expect(() => { migrateMode(); migrateStore(); writeFlag("crit", true); }).not.toThrow();
|
||
expect(readPrefs().snap).toBe(true);
|
||
});
|
||
});
|
||
```
|
||
|
||
Run: `cd web && npx vitest run src/board/storage.test.ts` → FAIL.
|
||
|
||
- [ ] **Step 2: Перенос**
|
||
- `storage.ts` ← строки 369–408 (`store`, `pref`, `BOARD`, `BOARD_KEYS`, `fileKey`, `migrateStore`, `strList`,
|
||
`laneOrder`, `foldedSet`, `colWidths`; `saveLayout` с параметрами `rel`, `colBounds`, `byDate` вместо глобальных и
|
||
`posKey()`), строка 1203 (`migrateMode`), строки 1204–1212 (`readPrefs`; тема — не здесь, она в `shell/theme.ts`),
|
||
строки 1130–1149 (`applyJsonSettings`: запись в хранилище как есть; вместо присваиваний `S.*` и `setToggle` — поля
|
||
возвращаемого `Partial<Filters>`; `drawLegend` не нужен), строки 807–814 (`toggleLane`, `moveLane` — только запись в
|
||
хранилище; перерисовку делает компонент), строки 691/699 (`setColWidth`), строка 1012 (`resetLayout`). Обращения к
|
||
`localStorage.length/key` в `migrateStore` — в `try/catch`, как в строке 384.
|
||
- `exportJson.ts` ← строки 1172–1184 (`MODEL`, `VIS`, `S` → параметры; `new Date().toISOString()` → `now`;
|
||
`saveLayout()` из первой строки не вызывать) и строка 1191 (`exportFileName`).
|
||
- `model/fromJson.ts` — удалить локальный `strList`, импортировать из `../storage.ts`.
|
||
|
||
- [ ] **Step 3: Прогнать**
|
||
|
||
Run: `cd web && npm run lint && npm test`
|
||
Expected: PASS — 8 тестов хранилища + прежние.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add web/src/board
|
||
git commit -m "feat(web): хранилище настроек доски с прежними ключами, импорт и экспорт JSON (#21)
|
||
|
||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
### Task 10: доска — источник данных и «Обновить из Jira» (M-1…M-5)
|
||
|
||
**Модель:** sonnet (интеграция TanStack Query, асинхронные тесты).
|
||
|
||
**Files:**
|
||
- Create: `web/src/board/useBoardSource.ts`, `web/src/board/status.ts`
|
||
- Test: `web/src/board/useBoardSource.test.tsx`, `web/src/board/status.test.ts`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `api`, `AuthError`, `HubDownError`, `ApiError`, `useBoard`, `qk` (Task 3); `clearToken` (Task 3); `fromHub`,
|
||
`fromCsv`, `fromJson`, `emptyModel`, `fmtStamp` (Task 6); типы `Batch`, `AgentView`, `CreatedBatch`, `SyncRun`.
|
||
- Produces:
|
||
|
||
```ts
|
||
// status.ts
|
||
export const POLL_MS = 3000;
|
||
export const MAX_POLL_ERRORS = 5;
|
||
export type BoardStatus =
|
||
| { kind: "loading" }
|
||
| { kind: "batch"; id: number; phase: "queued" | "running"; remaining: number; agentOffline: boolean; retrying: boolean }
|
||
| { kind: "batch-done"; id: number }
|
||
| { kind: "batch-failed"; id: number; error: string }
|
||
| { kind: "batch-cancelled"; id: number }
|
||
| { kind: "batch-lost"; id: number; reason: string }
|
||
| { kind: "hub-down"; hasData: boolean }
|
||
| { kind: "file-error"; fileName: string; reason: string }
|
||
| { kind: "json-applied"; fileName: string }
|
||
| { kind: "error"; message: string };
|
||
export function statusText(s: BoardStatus): { text: string; warn: boolean };
|
||
// useBoardSource.ts
|
||
export interface BoardSource {
|
||
model: BoardModel; // всегда есть: при загрузке и без данных — emptyModel с причиной
|
||
source: "hub" | "file";
|
||
label: string; // источник: «Хаб · данные на 14.10 12:01», «sprint2.csv (из файла)»
|
||
title: string; // подсказка к label (строка 1074 links.html)
|
||
status: BoardStatus | null; // одно текущее событие строки состояния (M-5)
|
||
refreshing: boolean; // «Обновить из Jira» недоступна (M-2)
|
||
reload(): void; // «Перечитать»: источник — хаб, снимок заново (M-1)
|
||
refreshFromJira(): Promise<void>;
|
||
loadFile(fileName: string, text: string): JsonImport | null; // JSON — вернёт настройки для применения
|
||
}
|
||
export function useBoardSource(dicts: Dicts | null, opts?: { pollMs?: number }): BoardSource;
|
||
```
|
||
|
||
Подписи строки состояния (`statusText`):
|
||
|
||
| `kind` | `text` | `warn` |
|
||
|---|---|---|
|
||
| `loading` | `загрузка данных хаба…` | нет |
|
||
| `batch` | `[агент офлайн — пакет выполнится, когда он выйдет на связь; ]пакет #N в очереди|выполняется[, осталось ~R с]…[ (хаб не отвечает, повтор через 3 с)]` | только при `retrying` |
|
||
| `batch-done` | `пакет #N выполнен` | нет |
|
||
| `batch-failed` | `пакет #N не выполнен: <error>` | да |
|
||
| `batch-cancelled` | `пакет #N отменён` | да |
|
||
| `batch-lost` | `пакет #N: <reason> — опрос остановлен, «Обновить из Jira» снова доступна` | да |
|
||
| `hub-down` | `hasData` ? `хаб не отвечает — показаны прежние данные` : `хаб не отвечает: запустите хаб (jira-hub serve) и нажмите «Перечитать»` | да |
|
||
| `file-error` | `файл не разобран: <reason>` | да |
|
||
| `json-applied` | `раскладка и фильтры из <fileName> применены к данным хаба` | нет |
|
||
| `error` | `<message>` | да |
|
||
|
||
- [ ] **Step 1: Тест подписей** — `web/src/board/status.test.ts`:
|
||
|
||
```ts
|
||
import { describe, expect, it } from "vitest";
|
||
import { statusText } from "./status.ts";
|
||
|
||
describe("statusText", () => {
|
||
it("пакет: офлайн, очередь, остаток, повтор", () => {
|
||
expect(statusText({ kind: "batch", id: 7, phase: "queued", remaining: 12, agentOffline: true, retrying: false }))
|
||
.toEqual({ text: "агент офлайн — пакет выполнится, когда он выйдет на связь; пакет #7 в очереди, осталось ~12 с…", warn: false });
|
||
expect(statusText({ kind: "batch", id: 7, phase: "running", remaining: 0, agentOffline: false, retrying: true }))
|
||
.toEqual({ text: "пакет #7 выполняется… (хаб не отвечает, повтор через 3 с)", warn: true });
|
||
});
|
||
it("исходы и ошибки", () => {
|
||
expect(statusText({ kind: "batch-failed", id: 7, error: "429" })).toEqual({ text: "пакет #7 не выполнен: 429", warn: true });
|
||
expect(statusText({ kind: "batch-lost", id: 7, reason: "пакета нет в хабе" }).text)
|
||
.toBe("пакет #7: пакета нет в хабе — опрос остановлен, «Обновить из Jira» снова доступна");
|
||
expect(statusText({ kind: "hub-down", hasData: true }).text).toBe("хаб не отвечает — показаны прежние данные");
|
||
expect(statusText({ kind: "file-error", fileName: "a.csv", reason: "в файле нет строк с задачами" }).text)
|
||
.toBe("файл не разобран: в файле нет строк с задачами");
|
||
});
|
||
});
|
||
```
|
||
|
||
`web/src/board/status.ts`:
|
||
|
||
```ts
|
||
export const POLL_MS = 3000; // период опроса пакета, мс (как у links.html)
|
||
export const MAX_POLL_ERRORS = 5; // столько ошибок «хаб не отвечает» подряд — и опрос пакета останавливается (M-4)
|
||
|
||
export type BoardStatus =
|
||
| { kind: "loading" }
|
||
| { kind: "batch"; id: number; phase: "queued" | "running"; remaining: number; agentOffline: boolean; retrying: boolean }
|
||
| { kind: "batch-done"; id: number }
|
||
| { kind: "batch-failed"; id: number; error: string }
|
||
| { kind: "batch-cancelled"; id: number }
|
||
| { kind: "batch-lost"; id: number; reason: string }
|
||
| { kind: "hub-down"; hasData: boolean }
|
||
| { kind: "file-error"; fileName: string; reason: string }
|
||
| { kind: "json-applied"; fileName: string }
|
||
| { kind: "error"; message: string };
|
||
|
||
// одна строка состояния доски: текст и признак предупреждения (M-5)
|
||
export function statusText(s: BoardStatus): { text: string; warn: boolean } {
|
||
switch (s.kind) {
|
||
case "loading": return { text: "загрузка данных хаба…", warn: false };
|
||
case "batch": {
|
||
const off = s.agentOffline ? "агент офлайн — пакет выполнится, когда он выйдет на связь; " : "";
|
||
const left = s.remaining ? `, осталось ~${s.remaining} с` : "";
|
||
const retry = s.retrying ? ` (хаб не отвечает, повтор через ${POLL_MS / 1000} с)` : "";
|
||
return { text: `${off}пакет #${s.id} ${s.phase === "queued" ? "в очереди" : "выполняется"}${left}…${retry}`, warn: s.retrying };
|
||
}
|
||
case "batch-done": return { text: `пакет #${s.id} выполнен`, warn: false };
|
||
case "batch-failed": return { text: `пакет #${s.id} не выполнен: ${s.error}`, warn: true };
|
||
case "batch-cancelled": return { text: `пакет #${s.id} отменён`, warn: true };
|
||
case "batch-lost": return { text: `пакет #${s.id}: ${s.reason} — опрос остановлен, «Обновить из Jira» снова доступна`, warn: true };
|
||
case "hub-down": return {
|
||
text: s.hasData ? "хаб не отвечает — показаны прежние данные" : "хаб не отвечает: запустите хаб (jira-hub serve) и нажмите «Перечитать»",
|
||
warn: true,
|
||
};
|
||
case "file-error": return { text: `файл не разобран: ${s.reason}`, warn: true };
|
||
case "json-applied": return { text: `раскладка и фильтры из ${s.fileName} применены к данным хаба`, warn: false };
|
||
case "error": return { text: s.message, warn: true };
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Падающие тесты хука** — `web/src/board/useBoardSource.test.tsx`:
|
||
|
||
```tsx
|
||
import { QueryClientProvider } from "@tanstack/react-query";
|
||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||
import { readFileSync } from "node:fs";
|
||
import type { ReactNode } from "react";
|
||
import { beforeEach, describe, expect, it } from "vitest";
|
||
import { makeQueryClient } from "../api/queryClient.ts";
|
||
import { setToken } from "../auth/token.ts";
|
||
import { json, mockFetch } from "../test/fetchMock.ts";
|
||
import { dictsFromSettings } from "./dicts.ts";
|
||
import settings from "./__fixtures__/settings.json";
|
||
import { useBoardSource } from "./useBoardSource.ts";
|
||
|
||
const fx = (name: string) => readFileSync(new URL(`./__fixtures__/${name}`, import.meta.url), "utf8");
|
||
const SNAP = JSON.parse(fx("hub-basic.board.json"));
|
||
const CSV = fx("jira-export.csv");
|
||
const { dicts } = dictsFromSettings(settings);
|
||
const wrapper = ({ children }: { children: ReactNode }) =>
|
||
<QueryClientProvider client={makeQueryClient()}>{children}</QueryClientProvider>;
|
||
const hook = () => renderHook(() => useBoardSource(dicts, { pollMs: 10 }), { wrapper });
|
||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||
const deferred = <T,>() => { let resolve!: (v: T) => void; const promise = new Promise<T>((r) => { resolve = r; }); return { promise, resolve }; };
|
||
|
||
// хаб в памяти: снимок, список пакетов, пакет по id; счётчики запросов
|
||
function hub(over: Partial<Record<string, (url: URL, body: unknown) => Response | Promise<Response>>> = {}) {
|
||
const calls: string[] = [];
|
||
const inFlight: Record<string, number> = {}, maxInFlight: Record<string, number> = {};
|
||
const fetch = mockFetch(async (method, url, body) => {
|
||
const k = `${method} ${url.pathname}`;
|
||
calls.push(k);
|
||
inFlight[k] = (inFlight[k] ?? 0) + 1;
|
||
maxInFlight[k] = Math.max(maxInFlight[k] ?? 0, inFlight[k]);
|
||
try {
|
||
const h = over[k] ?? over[`${method} ${url.pathname.replace(/\d+$/, ":id")}`];
|
||
if (h) return await h(url, body);
|
||
if (k === "GET /board") return json(200, SNAP);
|
||
if (k === "GET /batches") return json(200, { batches: [] });
|
||
if (k === "GET /agent") return json(200, { online: true, last_seen_at: null, warning: null });
|
||
if (k === "POST /refresh") return json(201, { id: 7, estimate_seconds: 5, count: 1 });
|
||
return json(404, { error: "not_found" });
|
||
} finally { inFlight[k] -= 1; }
|
||
});
|
||
return { fetch, calls, count: (k: string) => calls.filter((c) => c === k).length, maxInFlight: (k: string) => maxInFlight[k] ?? 0 };
|
||
}
|
||
const batch = (status: string, cmd: Record<string, unknown> = { status: "done" }) =>
|
||
json(200, { id: 7, title: "Обновление", author: "viewer", created_at: "", cancelled_at: null, status, counts: {}, remaining_seconds: 0, commands: [{ id: 1, seq: 1, type: "refresh", error: null, finished_at: null, ...cmd }] });
|
||
|
||
describe("useBoardSource", () => {
|
||
beforeEach(() => setToken("tok"));
|
||
|
||
it("строит доску из снимка хаба и подписывает источник", async () => {
|
||
hub();
|
||
const { result } = hook();
|
||
expect(result.current.status).toEqual({ kind: "loading" });
|
||
await waitFor(() => expect(result.current.model.nodes.length).toBe(10));
|
||
expect(result.current.source).toBe("hub");
|
||
expect(result.current.label).toMatch(/^Хаб · данные на /);
|
||
expect(result.current.status).toBeNull();
|
||
});
|
||
|
||
it("M-1: поздний ответ хаба не затирает загруженный CSV; «Перечитать» возвращает хаб", async () => {
|
||
const slow = deferred<Response>();
|
||
const h = hub({ "GET /board": () => slow.promise });
|
||
const { result } = hook();
|
||
act(() => { result.current.loadFile("jira-export.csv", CSV); });
|
||
expect(result.current.source).toBe("file");
|
||
slow.resolve(json(200, SNAP));
|
||
await waitFor(() => expect(h.count("GET /board")).toBe(1));
|
||
await sleep(20);
|
||
expect(result.current.source).toBe("file");
|
||
expect(result.current.label).toBe("jira-export.csv (из файла)");
|
||
act(() => result.current.reload());
|
||
await waitFor(() => expect(result.current.source).toBe("hub"));
|
||
await waitFor(() => expect(result.current.model.nodes.length).toBe(10));
|
||
});
|
||
|
||
it("M-2: двойное нажатие — один пакет и один опрос за раз", async () => {
|
||
let polls = 0;
|
||
const h = hub({ "GET /batches/:id": () => (++polls < 4 ? batch("running") : batch("done")) });
|
||
const { result } = hook();
|
||
await waitFor(() => expect(result.current.model.nodes.length).toBe(10));
|
||
await act(async () => { void result.current.refreshFromJira(); await result.current.refreshFromJira(); });
|
||
expect(h.count("POST /refresh")).toBe(1);
|
||
expect(result.current.refreshing).toBe(true);
|
||
await waitFor(() => expect(result.current.status).toEqual({ kind: "batch-done", id: 7 }));
|
||
expect(result.current.refreshing).toBe(false);
|
||
expect(h.maxInFlight("GET /batches/7")).toBe(1);
|
||
expect(h.count("GET /board")).toBe(2); // по завершении снимок перечитан
|
||
});
|
||
|
||
it("M-3: незавершённый пакет viewer подхватывается и после «Перечитать»", async () => {
|
||
let listed: unknown[] = [];
|
||
const h = hub({ "GET /batches": () => json(200, { batches: listed }), "GET /batches/:id": () => batch("running") });
|
||
const { result } = hook();
|
||
await waitFor(() => expect(h.count("GET /batches")).toBe(1));
|
||
expect(result.current.refreshing).toBe(false);
|
||
listed = [{ id: 7, author: "viewer", status: "running" }, { id: 6, author: "chat", status: "running" }];
|
||
act(() => result.current.reload());
|
||
await waitFor(() => expect(result.current.refreshing).toBe(true));
|
||
await waitFor(() => expect(result.current.status).toMatchObject({ kind: "batch", id: 7, phase: "running" }));
|
||
});
|
||
|
||
it("M-4: 404 на опросе — опрос остановлен, кнопка доступна", async () => {
|
||
const h = hub({ "GET /batches/:id": () => json(404, { error: "not_found", detail: "batch" }) });
|
||
const { result } = hook();
|
||
await waitFor(() => expect(result.current.model.nodes.length).toBe(10));
|
||
await act(() => result.current.refreshFromJira());
|
||
await waitFor(() => expect(result.current.status).toMatchObject({ kind: "batch-lost", id: 7 }));
|
||
expect(result.current.refreshing).toBe(false);
|
||
const n = h.count("GET /batches/7");
|
||
await sleep(50);
|
||
expect(h.count("GET /batches/7")).toBe(n);
|
||
});
|
||
|
||
it("M-4: 5 ошибок «хаб не отвечает» подряд — опрос остановлен", async () => {
|
||
const h = hub({ "GET /batches/:id": () => { throw new TypeError("Failed to fetch"); } });
|
||
const { result } = hook();
|
||
await waitFor(() => expect(result.current.model.nodes.length).toBe(10));
|
||
await act(() => result.current.refreshFromJira());
|
||
await waitFor(() => expect(result.current.status).toMatchObject({ kind: "batch-lost", reason: "хаб не отвечает" }));
|
||
expect(h.count("GET /batches/7")).toBe(5);
|
||
await sleep(50);
|
||
expect(h.count("GET /batches/7")).toBe(5);
|
||
expect(result.current.refreshing).toBe(false);
|
||
});
|
||
|
||
it("пакет с ошибкой команды — текст ошибки; агент офлайн — предупреждение в статусе", async () => {
|
||
let polls = 0;
|
||
hub({
|
||
"GET /agent": () => json(200, { online: false, last_seen_at: null, warning: null }),
|
||
"GET /batches/:id": () => (++polls < 3 ? batch("running") : batch("done", { status: "failed", error: "Jira 500" })),
|
||
});
|
||
const { result } = hook();
|
||
await waitFor(() => expect(result.current.model.nodes.length).toBe(10));
|
||
await act(() => result.current.refreshFromJira());
|
||
await waitFor(() => expect(result.current.status).toMatchObject({ kind: "batch", agentOffline: true }));
|
||
await waitFor(() => expect(result.current.status).toEqual({ kind: "batch-failed", id: 7, error: "Jira 500" }));
|
||
});
|
||
|
||
it("M-1: пакет, завершившийся при показанном файле, не заменяет файл", async () => {
|
||
let polls = 0;
|
||
hub({ "GET /batches/:id": () => (++polls < 3 ? batch("running") : batch("done")) });
|
||
const { result } = hook();
|
||
await waitFor(() => expect(result.current.model.nodes.length).toBe(10));
|
||
await act(() => result.current.refreshFromJira());
|
||
act(() => { result.current.loadFile("jira-export.csv", CSV); });
|
||
await waitFor(() => expect(result.current.status).toEqual({ kind: "batch-done", id: 7 }));
|
||
expect(result.current.source).toBe("file");
|
||
});
|
||
|
||
it("хаб остановлен при «Перечитать» — прежние данные и hub-down", async () => {
|
||
let down = false;
|
||
hub({ "GET /board": () => { if (down) throw new TypeError("Failed to fetch"); return json(200, SNAP); } });
|
||
const { result } = hook();
|
||
await waitFor(() => expect(result.current.model.nodes.length).toBe(10));
|
||
down = true;
|
||
act(() => result.current.reload());
|
||
await waitFor(() => expect(result.current.status).toEqual({ kind: "hub-down", hasData: true }));
|
||
expect(result.current.model.nodes.length).toBe(10);
|
||
});
|
||
|
||
it("плохой файл — file-error, прежняя доска на месте", async () => {
|
||
hub();
|
||
const { result } = hook();
|
||
await waitFor(() => expect(result.current.model.nodes.length).toBe(10));
|
||
act(() => { result.current.loadFile("bad.csv", "Номер,Название\n1,x\n"); });
|
||
expect(result.current.status).toMatchObject({ kind: "file-error", fileName: "bad.csv" });
|
||
expect(result.current.source).toBe("hub");
|
||
});
|
||
|
||
it("импорт JSON при живом хабе — настройки возвращаются, данные из хаба", async () => {
|
||
hub();
|
||
const { result } = hook();
|
||
await waitFor(() => expect(result.current.model.nodes.length).toBe(10));
|
||
let r: unknown = null;
|
||
act(() => { r = result.current.loadFile("old-board.json", fx("old-board.json")); });
|
||
expect(r).toMatchObject({ laneOrder: ["Гудвилл", "Тестовый эпик"] });
|
||
expect(result.current.source).toBe("hub");
|
||
expect(result.current.status).toEqual({ kind: "json-applied", fileName: "old-board.json" });
|
||
});
|
||
});
|
||
```
|
||
|
||
Run: `cd web && npx vitest run src/board/useBoardSource.test.tsx` → FAIL.
|
||
|
||
- [ ] **Step 3: Реализация** — `web/src/board/useBoardSource.ts`:
|
||
|
||
```ts
|
||
// источник данных доски: снимок хаба (GET /board) или файл (CSV/JSON); обновление из Jira — пакет refresh от имени
|
||
// viewer и опрос его хода. Устройство закрывает недочёты jira-hub#20: явный источник (M-1), одна кнопка и один опрос
|
||
// (M-2), подхват незавершённого пакета после любой загрузки (M-3), остановка опроса (M-4), одно событие состояния (M-5)
|
||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||
import { api, ApiError, AuthError, HubDownError } from "../api/client.ts";
|
||
import { qk, useBoard } from "../api/queries.ts";
|
||
import type { AgentView, Batch, CreatedBatch } from "../api/types.ts";
|
||
import { clearToken } from "../auth/token.ts";
|
||
import { fromCsv } from "./model/fromCsv.ts";
|
||
import { fromHub } from "./model/fromHub.ts";
|
||
import { fromJson } from "./model/fromJson.ts";
|
||
import { emptyModel, fmtStamp } from "./model/util.ts";
|
||
import { MAX_POLL_ERRORS, POLL_MS, type BoardStatus } from "./status.ts";
|
||
import type { BoardModel, Dicts, JsonImport } from "./types.ts";
|
||
|
||
type Source = { kind: "hub" } | { kind: "file"; model: BoardModel };
|
||
interface Active { id: number; agentOffline: boolean }
|
||
|
||
export interface BoardSource {
|
||
model: BoardModel;
|
||
source: "hub" | "file";
|
||
label: string;
|
||
title: string;
|
||
status: BoardStatus | null;
|
||
refreshing: boolean;
|
||
reload(): void;
|
||
refreshFromJira(): Promise<void>;
|
||
loadFile(fileName: string, text: string): JsonImport | null;
|
||
}
|
||
|
||
const terminal = (b: Batch | undefined) => !!b && (b.status === "done" || b.status === "cancelled");
|
||
|
||
export function useBoardSource(dicts: Dicts | null, { pollMs = POLL_MS }: { pollMs?: number } = {}): BoardSource {
|
||
const qc = useQueryClient();
|
||
const board = useBoard();
|
||
const [source, setSource] = useState<Source>({ kind: "hub" });
|
||
const [event, setEvent] = useState<BoardStatus | null>(null);
|
||
const [active, setActive] = useState<Active | null>(null);
|
||
const [starting, setStarting] = useState(false);
|
||
const startingRef = useRef(false);
|
||
const failures = useRef(0);
|
||
|
||
const hubModel = useMemo(() => (board.data && dicts ? fromHub(board.data, dicts) : null), [board.data, dicts]);
|
||
|
||
const report = useCallback((e: unknown, hasData: boolean) => {
|
||
if (e instanceof AuthError) clearToken("bad");
|
||
else if (e instanceof HubDownError) setEvent({ kind: "hub-down", hasData });
|
||
else setEvent({ kind: "error", message: e instanceof Error ? e.message : String(e) });
|
||
}, []);
|
||
|
||
// хаб не ответил на снимок: прежние данные остаются (M-5 — отдельное событие, не затирается другими)
|
||
useEffect(() => {
|
||
if (board.error && !(board.error instanceof AuthError)) report(board.error, !!board.data);
|
||
}, [board.error, board.errorUpdatedAt, board.data, report]);
|
||
|
||
// M-3: после каждой успешной загрузки снимка — подхватить незавершённый пакет viewer
|
||
useEffect(() => {
|
||
if (!board.dataUpdatedAt || active) return;
|
||
let gone = false;
|
||
api.get<{ batches: Batch[] }>("/batches", { limit: 20 }).then((r) => {
|
||
const b = r.batches.find((x) => x.author === "viewer" && (x.status === "queued" || x.status === "running"));
|
||
if (b && !gone) { failures.current = 0; setActive((cur) => cur ?? { id: b.id, agentOffline: false }); }
|
||
}).catch(() => { /* не критично: подхватим при следующей загрузке */ });
|
||
return () => { gone = true; };
|
||
}, [board.dataUpdatedAt, active]);
|
||
|
||
// M-2, M-4: один опрос пакета; остановка на завершении, 404, 401 и после MAX_POLL_ERRORS ошибок подряд
|
||
const batch = useQuery({
|
||
queryKey: ["batch", active?.id ?? 0],
|
||
enabled: active !== null,
|
||
gcTime: 0,
|
||
staleTime: 0,
|
||
queryFn: async () => {
|
||
try {
|
||
const b = await api.get<Batch>(`/batches/${active!.id}`);
|
||
failures.current = 0;
|
||
return b;
|
||
} catch (e) {
|
||
if (e instanceof HubDownError) failures.current += 1;
|
||
throw e;
|
||
}
|
||
},
|
||
refetchInterval: (q) => {
|
||
const e = q.state.error;
|
||
if (terminal(q.state.data)) return false;
|
||
if (e instanceof AuthError || (e instanceof ApiError && e.status === 404)) return false;
|
||
if (failures.current >= MAX_POLL_ERRORS) return false;
|
||
return pollMs;
|
||
},
|
||
refetchIntervalInBackground: true,
|
||
});
|
||
|
||
const finish = useCallback((id: number) => {
|
||
failures.current = 0;
|
||
setActive(null);
|
||
qc.removeQueries({ queryKey: ["batch", id] });
|
||
void qc.invalidateQueries({ queryKey: qk.board }); // снимок обновится; показанный файл не заменяется (M-1)
|
||
}, [qc]);
|
||
|
||
useEffect(() => {
|
||
if (!active) return;
|
||
const b = batch.data, e = batch.error;
|
||
if (b && b.id === active.id && !batch.isError) {
|
||
if (terminal(b)) {
|
||
const cmd = b.commands?.[0];
|
||
setEvent(cmd?.status === "failed" ? { kind: "batch-failed", id: b.id, error: cmd.error || "ошибка агента" }
|
||
: b.status === "cancelled" || cmd?.status === "cancelled" || cmd?.status === "skipped" ? { kind: "batch-cancelled", id: b.id }
|
||
: { kind: "batch-done", id: b.id });
|
||
finish(b.id);
|
||
return;
|
||
}
|
||
setEvent({ kind: "batch", id: b.id, phase: b.status === "queued" ? "queued" : "running",
|
||
remaining: b.remaining_seconds, agentOffline: active.agentOffline, retrying: false });
|
||
return;
|
||
}
|
||
if (e instanceof ApiError && e.status === 404) {
|
||
setEvent({ kind: "batch-lost", id: active.id, reason: "пакета нет в хабе" });
|
||
finish(active.id);
|
||
} else if (e instanceof HubDownError) {
|
||
if (failures.current >= MAX_POLL_ERRORS) {
|
||
setEvent({ kind: "batch-lost", id: active.id, reason: "хаб не отвечает" });
|
||
finish(active.id);
|
||
} else {
|
||
setEvent((cur) => (cur?.kind === "batch" ? { ...cur, retrying: true } : cur));
|
||
}
|
||
}
|
||
}, [batch.data, batch.error, batch.isError, batch.errorUpdatedAt, batch.dataUpdatedAt, active, finish]);
|
||
|
||
const refreshFromJira = useCallback(async () => {
|
||
if (startingRef.current || active) return;
|
||
startingRef.current = true;
|
||
setStarting(true);
|
||
try {
|
||
const agent = await api.get<AgentView>("/agent");
|
||
const b = await api.post<CreatedBatch>("/refresh", { full: true, author: "viewer" });
|
||
failures.current = 0;
|
||
setActive({ id: b.id, agentOffline: !agent.online });
|
||
setEvent({ kind: "batch", id: b.id, phase: "queued", remaining: b.estimate_seconds, agentOffline: !agent.online, retrying: false });
|
||
} catch (e) {
|
||
report(e, !!board.data);
|
||
} finally {
|
||
startingRef.current = false;
|
||
setStarting(false);
|
||
}
|
||
}, [active, report, board.data]);
|
||
|
||
const reload = useCallback(() => {
|
||
setSource({ kind: "hub" });
|
||
setEvent(null);
|
||
void qc.refetchQueries({ queryKey: qk.board });
|
||
}, [qc]);
|
||
|
||
const loadFile = useCallback((fileName: string, text: string): JsonImport | null => {
|
||
if (!dicts) return null;
|
||
const isJson = /\.json$/i.test(fileName) || /^?\s*\{/.test(text);
|
||
try {
|
||
if (!isJson) {
|
||
setSource({ kind: "file", model: fromCsv(text, fileName, dicts) });
|
||
setEvent(null);
|
||
return null;
|
||
}
|
||
const r = fromJson(text, fileName, dicts);
|
||
if (board.data) { // режим хаба: из файла — раскладка и фильтры, данные — из хаба
|
||
setSource({ kind: "hub" });
|
||
setEvent({ kind: "json-applied", fileName });
|
||
void qc.refetchQueries({ queryKey: qk.board });
|
||
} else { // хаб не ответил — снимок из файла
|
||
setSource({ kind: "file", model: r.model });
|
||
setEvent({ kind: "hub-down", hasData: true });
|
||
}
|
||
return r;
|
||
} catch (e) {
|
||
setEvent({ kind: "file-error", fileName, reason: e instanceof Error ? e.message : String(e) });
|
||
return null;
|
||
}
|
||
}, [dicts, board.data, qc]);
|
||
|
||
const ls = board.data?.last_sync ?? null;
|
||
const model = source.kind === "file" ? source.model : hubModel ?? emptyModel(board.isPending
|
||
? "Загрузка данных хаба…"
|
||
: "Нет данных: хаб не отвечает. Запустите хаб (jira-hub serve) и нажмите «Перечитать» или загрузите файл CSV из фильтра Jira.");
|
||
const label = source.kind === "file" ? `${source.model.fileName} (из файла)`
|
||
: board.data ? (ls ? `Хаб · данные на ${fmtStamp(ls.finished_at)}` : "Хаб · полного обновления не было")
|
||
: board.isPending ? "Хаб" : "Хаб недоступен";
|
||
const title = source.kind === "file" ? `${source.model.fileName} — снимок из файла; «Перечитать» вернёт данные хаба`
|
||
: ls && board.data ? `Последнее полное обновление из Jira: ${fmtStamp(ls.started_at)} – ${fmtStamp(ls.finished_at)}, задач: ${ls.issues_count}. Снимок хаба: ${fmtStamp(board.data.generated_at)}`
|
||
: label;
|
||
const status: BoardStatus | null = event ?? (source.kind === "hub" && board.isPending ? { kind: "loading" } : null);
|
||
|
||
return { model, source: source.kind, label, title, status, refreshing: starting || active !== null, reload, refreshFromJira, loadFile };
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Прогнать**
|
||
|
||
Run: `cd web && npm run lint && npx vitest run src/board`
|
||
Expected: PASS — 11 тестов хука, 2 подписей, прежние. Если тест M-4 с пятью ошибками нестабилен (лишний запрос из-за
|
||
`refetchInterval`), проверить, что счётчик `failures` увеличивается в `queryFn` до вычисления `refetchInterval`, а не в
|
||
эффекте; таймауты тестов не увеличивать.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add web/src/board/useBoardSource.ts web/src/board/status.ts web/src/board/*.test.ts*
|
||
git commit -m "feat(web): источник данных доски и «Обновить из Jira» — закрывает M-1…M-5 #20 (#21)
|
||
|
||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
### Task 11: доска — холст: дорожки, карточки, стрелки, перетаскивание, колонки дат
|
||
|
||
**Модель:** sonnet (React + pointer-события; поведение сверяется со строками `links.html`).
|
||
|
||
**Files:**
|
||
- Create: `web/src/board/components/BoardCanvas.tsx`, `web/src/board/components/Card.tsx`,
|
||
`web/src/board/components/Lane.tsx`, `web/src/board/components/Edges.tsx`, `web/src/board/components/DatesBar.tsx`,
|
||
`web/src/board/board.css`
|
||
- Test: `web/src/board/components/BoardCanvas.test.tsx`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `computeLayout`, `place`, `measuredHeight`, `clampToCol`, `lineClamp`, `dateText`, `dateTitle` (Task 8),
|
||
`edgePath`, `alignEdges` (Task 8), `saveLayout` (Task 9), константы (Task 6).
|
||
- Produces:
|
||
|
||
```ts
|
||
export interface LayoutStore {
|
||
laneOrder: string[]; folded: Set<string>; colWidths: Record<string, number>;
|
||
version: number; // растёт при каждой записи раскладки в хранилище (сброс, импорт JSON) — повод пересчитать раскладку
|
||
}
|
||
export interface BoardCanvasProps {
|
||
vis: Visible; byDate: boolean; snap: boolean; today: string; store: LayoutStore; sprintSel: Set<string>;
|
||
emptyText: string; // текст пустой доски (emptyReason)
|
||
onToggleLane(name: string): void;
|
||
onMoveLane(name: string, to: number): void; // to — новый индекс среди vis.lanes
|
||
onColWidth(date: string, w: number | null): void; // null — вернуть автоматическую ширину
|
||
children?: ReactNode; // боковая панель внутри .stage (ящик «Проблемы»)
|
||
}
|
||
export function BoardCanvas(p: BoardCanvasProps): JSX.Element;
|
||
export const Card: React.MemoExoticComponent<(p: CardProps) => JSX.Element>;
|
||
```
|
||
|
||
Разметка и классы — как у `links.html` (строки 296–302 и функции `renderLane`, `renderCard`, `renderDateCols`,
|
||
`drawEdges`), чтобы стили переносились без изменений. Новое — только значок «ⓘ» (решение R-1).
|
||
|
||
- [ ] **Step 1: Стили** — `web/src/board/board.css` ← CSS ревизии 3931a88: из раздела 3 (строки 42–51) всё, кроме
|
||
`.link…` (они в `controls.css`); разделы 5–9 (строки 92–237) целиком. Добавить в конец:
|
||
|
||
```css
|
||
/* «Открыть карточку» в заголовке карточки (R-1) */
|
||
.card .open-card{margin-left:6px;color:var(--muted);text-decoration:none;font-size:13px}
|
||
.card .open-card:hover,.card .open-card:focus-visible{color:var(--accent);outline:none}
|
||
/* слой измерения: карточки шириной CW с высотой по содержимому, невидим */
|
||
.measure{position:absolute;left:-100000px;top:0;visibility:hidden;pointer-events:none}
|
||
.measure .card{position:static}
|
||
.measure .card .title{-webkit-line-clamp:unset}
|
||
```
|
||
|
||
- [ ] **Step 2: Падающий тест холста** — `web/src/board/components/BoardCanvas.test.tsx`:
|
||
|
||
```tsx
|
||
import { fireEvent, screen, within } from "@testing-library/react";
|
||
import { readFileSync } from "node:fs";
|
||
import { describe, expect, it, vi } from "vitest";
|
||
import type { BoardSnapshot } from "../../api/types.ts";
|
||
import { renderWithProviders } from "../../test/render.tsx";
|
||
import { computeVisible } from "../analysis/visible.ts";
|
||
import { dictsFromSettings } from "../dicts.ts";
|
||
import settings from "../__fixtures__/settings.json";
|
||
import { fromHub } from "../model/fromHub.ts";
|
||
import { noFilters } from "../types.ts";
|
||
import { BoardCanvas, type LayoutStore } from "./BoardCanvas.tsx";
|
||
|
||
const fx = (name: string) => readFileSync(new URL(`../__fixtures__/${name}`, import.meta.url), "utf8");
|
||
const { dicts } = dictsFromSettings(settings);
|
||
const model = fromHub(JSON.parse(fx("hub-basic.board.json")) as BoardSnapshot, dicts);
|
||
const store: LayoutStore = { laneOrder: [], folded: new Set(), colWidths: {}, version: 0 };
|
||
|
||
function draw(over: Partial<Parameters<typeof BoardCanvas>[0]> = {}) {
|
||
const f = { ...noFilters(), ...(over.byDate ? { byDate: true } : {}) };
|
||
const vis = computeVisible(model, f, [], "2026-10-14");
|
||
const props = { vis, byDate: false, snap: true, today: "2026-10-14", store, sprintSel: new Set<string>(), emptyText: "пусто",
|
||
onToggleLane: vi.fn(), onMoveLane: vi.fn(), onColWidth: vi.fn(), ...over };
|
||
renderWithProviders(<BoardCanvas {...props} />);
|
||
return props;
|
||
}
|
||
const board = () => document.querySelector(".board") as HTMLElement;
|
||
|
||
describe("BoardCanvas", () => {
|
||
it("дорожки по эпикам и карточки задач с заглушкой", () => {
|
||
draw();
|
||
const lanes = [...board().querySelectorAll(".lane-name")].map((e) => e.textContent);
|
||
expect(lanes).toEqual(["AURORA-9999", "Гудвилл", "Тестовый эпик", "Без эпика", "Нет в зеркале"]);
|
||
expect(board().querySelectorAll(".card[data-key]")).toHaveLength(10);
|
||
expect(within(board()).getByText("нет в зеркале")).toBeInTheDocument();
|
||
});
|
||
|
||
it("ключ ведёт в Jira, ⓘ — в карточку приложения; у заглушки ⓘ нет", () => {
|
||
draw();
|
||
const card = board().querySelector('.card[data-key="AURORA-9001"]') as HTMLElement;
|
||
expect(within(card).getByRole("link", { name: "AURORA-9001" })).toHaveAttribute("href", "https://sdlc.go.rshbank.ru/jira/browse/AURORA-9001");
|
||
expect(within(card).getByRole("link", { name: "Открыть карточку AURORA-9001" })).toHaveAttribute("href", "/issues/AURORA-9001");
|
||
const stub = board().querySelector('.card[data-key="AURORA-9500"]') as HTMLElement;
|
||
expect(within(stub).queryByRole("link", { name: /Открыть карточку/ })).toBeNull();
|
||
});
|
||
|
||
it("стрелки: по одной на видимую связь, «связанные» пунктиром, блокирующие со стрелкой", () => {
|
||
draw();
|
||
const paths = [...document.querySelectorAll("svg.edges path")];
|
||
expect(paths).toHaveLength(7);
|
||
expect(paths.filter((p) => p.getAttribute("stroke-dasharray") === "5 4")).toHaveLength(1);
|
||
expect(paths.filter((p) => p.getAttribute("marker-end"))).toHaveLength(6);
|
||
});
|
||
|
||
it("свёртка и перестановка дорожек — через колбэки", () => {
|
||
const p = draw();
|
||
const lane = [...board().querySelectorAll(".lane")].find((l) => l.textContent?.includes("Гудвилл")) as HTMLElement;
|
||
fireEvent.click(within(lane).getByTitle("Свернуть или развернуть дорожку"));
|
||
expect(p.onToggleLane).toHaveBeenCalledWith("Гудвилл");
|
||
fireEvent.click(within(lane).getByTitle("Ниже"));
|
||
expect(p.onMoveLane).toHaveBeenCalledWith("Гудвилл", 2);
|
||
});
|
||
|
||
it("свёрнутая дорожка прячет свои карточки", () => {
|
||
draw({ store: { ...store, folded: new Set(["Гудвилл"]) } });
|
||
expect(board().querySelector('.card[data-key="AURORA-9004"]')).toBeNull();
|
||
expect(board().querySelector(".lane.folded")).not.toBeNull();
|
||
});
|
||
|
||
it("«по датам»: колонки и подписи дат, двойной щелчок по границе — автоматическая ширина", () => {
|
||
const p = draw({ byDate: true });
|
||
expect(board().querySelectorAll(".dcol").length).toBeGreaterThan(0);
|
||
expect(screen.getByText("16.10")).toBeInTheDocument();
|
||
fireEvent.doubleClick(document.querySelector(".dsplit") as HTMLElement);
|
||
expect(p.onColWidth).toHaveBeenCalledWith(expect.any(String), null);
|
||
});
|
||
|
||
it("пустая выборка — текст причины", () => {
|
||
draw({ vis: computeVisible(model, { ...noFilters(), who: new Set(["Никто"]) }, [], "2026-10-14"), emptyText: "Фильтры не оставили" });
|
||
expect(screen.getByText("Фильтры не оставили")).toBeInTheDocument();
|
||
});
|
||
});
|
||
```
|
||
|
||
Run: `cd web && npx vitest run src/board/components` → FAIL.
|
||
|
||
- [ ] **Step 3: `Card.tsx`** (разметка строк 734–751):
|
||
|
||
```tsx
|
||
import { memo, type PointerEvent } from "react";
|
||
import { Link } from "react-router";
|
||
import { CW, JIRA_URL, KIND, PRIO } from "../constants.ts";
|
||
import { lineClamp, type Rect, type RelBox } from "../layout/layout.ts";
|
||
import { fmtDate, shortName, unassigned } from "../model/util.ts";
|
||
import type { VisNode } from "../types.ts";
|
||
|
||
export interface CardProps {
|
||
node: VisNode;
|
||
box?: Rect; // нет — карточка слоя измерения (ширина CW, высота по содержимому)
|
||
rel?: RelBox;
|
||
multiSprint: boolean;
|
||
sprintSel: Set<string>;
|
||
onPointerDown?: (ev: PointerEvent<HTMLDivElement>, key: string) => void;
|
||
onAutoSize?: (key: string) => void;
|
||
}
|
||
|
||
export const Card = memo(function Card({ node: n, box, rel, multiSprint, sprintSel, onPointerDown, onAutoSize }: CardProps) {
|
||
const cls = "card" + (n.dim || n.stub ? " out" : "") + (n.crit ? " crit" : "") + (n.focus ? " focus" : "");
|
||
const style = box ? { left: box.x, top: box.y, width: box.w, height: box.h } : { width: CW };
|
||
const showTag = !n.stub && (multiSprint || n.dim); // метка спринта — если спринтов на доске больше одного или это сосед
|
||
return (
|
||
<div className={cls} title={n.title} style={style} data-key={n.key}
|
||
onPointerDown={onPointerDown ? (e) => onPointerDown(e, n.key) : undefined}>
|
||
<div className="head">
|
||
<a className="key" href={`${JIRA_URL}/browse/${n.key}`} target="_blank" rel="noopener">{n.key}</a>
|
||
{!n.stub && <Link className="open-card" to={`/issues/${n.key}`} title="Открыть карточку задачи"
|
||
aria-label={`Открыть карточку ${n.key}`}>ⓘ</Link>}
|
||
{showTag && (n.sprint
|
||
? <span className={"sprint-tag" + (sprintSel.has(n.sprint) ? " sel" : "")} title={n.sprint}>{n.sprint.replace(/^AWPCA_/, "")}</span>
|
||
: <span className="sprint-tag none">без спринта</span>)}
|
||
</div>
|
||
<div className="title" style={{ WebkitLineClamp: box && rel ? lineClamp(rel) : "unset" }}>{n.title}</div>
|
||
<div className="meta">
|
||
<span className="kind"><i style={{ background: `var(--k-${KIND[n.kind] || "org"})` }} />{n.kind}</span>
|
||
<span className="prio" style={{ color: `var(--p-${PRIO[n.prio] || "mid"})` }}>{n.prio}</span>
|
||
</div>
|
||
{(n.status || n.due) && (
|
||
<div className="state">
|
||
{n.status && <span className="status" title={n.status}>{n.status}</span>}
|
||
{n.due && <span className="due">до {fmtDate(n.due)}</span>}
|
||
</div>
|
||
)}
|
||
{!n.stub && <div className={"who" + (unassigned(n.who) ? " none" : "")} title={n.who}>{shortName(n.who)}</div>}
|
||
<div className="rsz" title="Потянуть — изменить размер; двойной щелчок — размер по содержимому"
|
||
onDoubleClick={onAutoSize ? () => onAutoSize(n.key) : undefined} />
|
||
<div className="grip t" title="Тянуть за рамку" /><div className="grip b" /><div className="grip l" /><div className="grip r" />
|
||
</div>
|
||
);
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 4: `Edges.tsx`** (строки 330–331 `ARROWS`, 838–849 `drawEdges`, 860–863 направляющие):
|
||
|
||
```tsx
|
||
import type { Placed } from "../layout/layout.ts";
|
||
import { edgePath } from "../layout/edges.ts";
|
||
import { RELATED } from "../constants.ts";
|
||
import type { VisLink } from "../types.ts";
|
||
|
||
export function Edges({ links, placed }: { links: VisLink[]; placed: Placed }) {
|
||
return (
|
||
<svg className="edges">
|
||
<defs>
|
||
<marker id="arr" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
||
<path d="M0 0 L10 5 L0 10 z" fill="var(--edge-strong)" />
|
||
</marker>
|
||
<marker id="arrHand" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
||
<path d="M0 0 L10 5 L0 10 z" fill="var(--guide)" />
|
||
</marker>
|
||
</defs>
|
||
{links.map((l) => {
|
||
const a = placed.pos[l.src], b = placed.pos[l.dst];
|
||
if (!a || !b) return null;
|
||
const related = l.type === RELATED;
|
||
return <path key={`${l.src}>${l.dst}${l.type}`} d={edgePath(a, b)} fill="none"
|
||
stroke={related ? "var(--edge)" : l.hand ? "var(--guide)" : "var(--edge-strong)"}
|
||
strokeWidth={l.crit ? "4" : related ? "1.4" : "1.8"}
|
||
strokeDasharray={related ? "5 4" : undefined}
|
||
markerEnd={related ? undefined : l.hand ? "url(#arrHand)" : "url(#arr)"} />;
|
||
})}
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
export function Guides({ x, y, width, height }: { x: number | null; y: number | null; width: number; height: number }) {
|
||
return (
|
||
<svg className="guides">
|
||
{x !== null && <line x1={x} y1={0} x2={x} y2={height} />}
|
||
{y !== null && <line x1={0} y1={y} x2={width} y2={y} />}
|
||
</svg>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: `Lane.tsx`** (строки 715–733):
|
||
|
||
```tsx
|
||
import { useRef, useState, type PointerEvent } from "react";
|
||
import type { LaneGeom } from "../layout/layout.ts";
|
||
import { plural } from "../model/util.ts";
|
||
|
||
interface LaneProps {
|
||
g: LaneGeom; index: number; last: number; count: number;
|
||
lanes: LaneGeom[]; // все дорожки — для выбора места при перетаскивании
|
||
boardTop: () => number; // верх доски в координатах окна
|
||
targetClass: string; // "target" / "target-after" — подсветка целевой границы (строка 729)
|
||
onToggle(name: string): void;
|
||
onMove(name: string, to: number): void;
|
||
onDragTarget(to: number | null): void; // цель перетаскивания этой дорожки; null — перетаскивание закончено
|
||
}
|
||
|
||
export function Lane({ g, index, last, count, lanes, boardTop, targetClass, onToggle, onMove, onDragTarget }: LaneProps) {
|
||
const [dragging, setDragging] = useState(false);
|
||
const nameRef = useRef<HTMLSpanElement>(null);
|
||
const where = (clientY: number) => {
|
||
const y = clientY - boardTop();
|
||
const i = lanes.findIndex((x) => y < x.y + x.h / 2);
|
||
return i < 0 ? lanes.length - 1 : i;
|
||
};
|
||
const down = (ev: PointerEvent<HTMLSpanElement>) => {
|
||
ev.preventDefault();
|
||
const el = nameRef.current!;
|
||
el.setPointerCapture(ev.pointerId);
|
||
let target = index;
|
||
setDragging(true);
|
||
const move = (e: globalThis.PointerEvent) => { target = where(e.clientY); onDragTarget(target); };
|
||
const stop = () => {
|
||
el.removeEventListener("pointermove", move); el.removeEventListener("pointerup", stop); el.removeEventListener("pointercancel", stop);
|
||
setDragging(false);
|
||
onDragTarget(null);
|
||
if (target !== index) onMove(g.name, target);
|
||
};
|
||
el.addEventListener("pointermove", move); el.addEventListener("pointerup", stop); el.addEventListener("pointercancel", stop);
|
||
};
|
||
const cls = "lane" + (g.folded ? " folded" : "") + (dragging ? " dragging" : "") + (targetClass ? " " + targetClass : "");
|
||
return (
|
||
<div className={cls} style={{ top: g.y, height: g.h }}>
|
||
<div className="lane-head">
|
||
<button type="button" className="fold" title="Свернуть или развернуть дорожку" onClick={() => onToggle(g.name)} />
|
||
<span ref={nameRef} className="lane-name" title="Перетащить, чтобы поменять дорожки местами" onPointerDown={down}>{g.name}</span>
|
||
<span className="lane-count">{plural(count, "задача", "задачи", "задач")}</span>
|
||
<span className="lane-tools">
|
||
<button type="button" title="Выше" disabled={index === 0} onClick={() => onMove(g.name, index - 1)}>▲</button>
|
||
<button type="button" title="Ниже" disabled={index === last} onClick={() => onMove(g.name, index + 1)}>▼</button>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: `DatesBar.tsx`** (строки 678–703, 705–714):
|
||
|
||
```tsx
|
||
import { Fragment, useRef, type PointerEvent, type RefObject } from "react";
|
||
import { COLW, GRID } from "../constants.ts";
|
||
import { dateText, dateTitle, type DateCol } from "../layout/layout.ts";
|
||
|
||
// липкая полоса подписей дат над доской: сдвиг — как у горизонтальной прокрутки доски; ручка границы меняет ширину колонки
|
||
export function DatesBar({ cols, width, scrollLeft, inRef, onColWidth }: {
|
||
cols: DateCol[]; width: number; scrollLeft: number; inRef: RefObject<HTMLDivElement | null>;
|
||
onColWidth(date: string, w: number | null): void;
|
||
}) {
|
||
const raf = useRef(0);
|
||
const down = (ev: PointerEvent<HTMLDivElement>, c: DateCol) => {
|
||
ev.preventDefault();
|
||
const handle = ev.currentTarget, x0 = ev.clientX;
|
||
handle.classList.add("on"); inRef.current?.classList.add("resizing");
|
||
let last = c.w;
|
||
const apply = (w: number) => onColWidth(c.date, w <= COLW ? null : Math.round(w / GRID) * GRID);
|
||
const move = (e: globalThis.PointerEvent) => {
|
||
last = Math.max(COLW, c.w + e.clientX - x0);
|
||
if (!raf.current) raf.current = requestAnimationFrame(() => { raf.current = 0; apply(last); });
|
||
};
|
||
const stop = () => {
|
||
window.removeEventListener("pointermove", move); window.removeEventListener("pointerup", stop); window.removeEventListener("pointercancel", stop);
|
||
handle.classList.remove("on"); inRef.current?.classList.remove("resizing");
|
||
apply(last);
|
||
};
|
||
window.addEventListener("pointermove", move); window.addEventListener("pointerup", stop); window.addEventListener("pointercancel", stop);
|
||
};
|
||
const cls = (c: DateCol) => (c.cls ? " " + c.cls : "");
|
||
return (
|
||
<div className="dates-bar">
|
||
<div className="dates-in" ref={inRef}>
|
||
<div className="dates-track" style={{ width, transform: `translateX(${-scrollLeft}px)` }}>
|
||
{cols.map((c) => (
|
||
<Fragment key={c.date}>
|
||
<span className={"dlabel" + cls(c)} style={{ left: c.x0 + 8 }} title={dateTitle(c)}>{dateText(c)}<span className="n">{c.count}</span></span>
|
||
<div className="dsplit" style={{ left: c.x0 + c.w }} title="Тянуть — ширина колонки; двойной щелчок — автоматическая"
|
||
onPointerDown={(e) => down(e, c)} onDoubleClick={() => onColWidth(c.date, null)} />
|
||
</Fragment>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 7: `BoardCanvas.tsx`**
|
||
|
||
```tsx
|
||
import { useCallback, useLayoutEffect, useMemo, useRef, useState, type PointerEvent, type ReactNode } from "react";
|
||
import { CW, GRID, MINH, MINW, TOP } from "../constants.ts";
|
||
import { alignEdges } from "../layout/align.ts";
|
||
import { clampToCol, computeLayout, measuredHeight, place, type Layout, type Placed, type RelBox } from "../layout/layout.ts";
|
||
import { saveLayout, savedLayout } from "../storage.ts";
|
||
import type { Visible } from "../types.ts";
|
||
import { Card } from "./Card.tsx";
|
||
import { DatesBar } from "./DatesBar.tsx";
|
||
import { Edges, Guides } from "./Edges.tsx";
|
||
import { Lane } from "./Lane.tsx";
|
||
import "../board.css";
|
||
|
||
export interface LayoutStore { laneOrder: string[]; folded: Set<string>; colWidths: Record<string, number>; version: number }
|
||
export interface BoardCanvasProps {
|
||
vis: Visible; byDate: boolean; snap: boolean; today: string; store: LayoutStore; sprintSel: Set<string>;
|
||
emptyText: string;
|
||
onToggleLane(name: string): void;
|
||
onMoveLane(name: string, to: number): void;
|
||
onColWidth(date: string, w: number | null): void;
|
||
children?: ReactNode;
|
||
}
|
||
|
||
export function BoardCanvas({ vis, byDate, snap, today, store, sprintSel, emptyText, onToggleLane, onMoveLane, onColWidth, children }: BoardCanvasProps) {
|
||
const boardRef = useRef<HTMLDivElement>(null);
|
||
const measureRef = useRef<HTMLDivElement>(null);
|
||
const wrapRef = useRef<HTMLDivElement>(null);
|
||
const hscrollRef = useRef<HTMLDivElement>(null);
|
||
const datesInRef = useRef<HTMLDivElement>(null);
|
||
const [layout, setLayout] = useState<Layout | null>(null);
|
||
const [rel, setRel] = useState<Record<string, RelBox>>({});
|
||
const [guides, setGuides] = useState<{ x: number | null; y: number | null } | null>(null);
|
||
const [scroll, setScroll] = useState({ left: 0, wide: false, wrapW: 0, scrollW: 0 });
|
||
const [dragTarget, setDragTarget] = useState<{ from: number; to: number } | null>(null);
|
||
|
||
// раскладка как render() links.html: при новой выборке, режиме и ширинах колонок — заново, с сохранённой из хранилища
|
||
useLayoutEffect(() => {
|
||
if (!vis.nodes.length) { setLayout(null); setRel({}); return; }
|
||
const els = new Map<string, HTMLElement>();
|
||
measureRef.current?.querySelectorAll<HTMLElement>(".card[data-key]").forEach((el) => els.set(el.dataset.key!, el));
|
||
const measure = (key: string) => {
|
||
const el = els.get(key), oh = el?.offsetHeight ?? 0;
|
||
return { h: measuredHeight(oh), other: oh - (el?.querySelector<HTMLElement>(".title")?.offsetHeight ?? 0) };
|
||
};
|
||
const lay = computeLayout(vis, { byDate, colWidths: store.colWidths, saved: savedLayout(byDate), today, measure });
|
||
setLayout(lay);
|
||
setRel(lay.rel);
|
||
}, [vis, byDate, store.colWidths, store.version, today]);
|
||
|
||
const placed = useMemo<Placed | null>(() => (layout ? place(layout, rel, store.folded) : null), [layout, rel, store.folded]);
|
||
const live = useRef({ placed, rel, layout });
|
||
useLayoutEffect(() => { live.current = { placed, rel, layout }; });
|
||
|
||
// перетаскивание за рамку и растяжка за уголок (строки 753–778): направляющие, сетка, не выше своей дорожки
|
||
const onCardDown = useCallback((ev: PointerEvent<HTMLDivElement>, key: string) => {
|
||
const t = ev.target as HTMLElement;
|
||
if (t.closest("a")) return;
|
||
const resizing = !!t.closest(".rsz");
|
||
if (!resizing && !t.closest(".grip")) return; // остальное — текст, его можно выделять
|
||
const { placed: pl, layout: lay } = live.current;
|
||
if (!pl || !lay || !pl.pos[key]) return;
|
||
ev.preventDefault();
|
||
const el = ev.currentTarget;
|
||
el.classList.add("drag");
|
||
el.setPointerCapture(ev.pointerId);
|
||
const q0 = { ...pl.pos[key] }, start = { x: ev.clientX, y: ev.clientY };
|
||
let cur = live.current.rel;
|
||
const move = (e: globalThis.PointerEvent) => {
|
||
const dx = e.clientX - start.x, dy = e.clientY - start.y;
|
||
const others = live.current.placed!.pos, r0 = cur[key];
|
||
let next: RelBox;
|
||
if (resizing) {
|
||
const g = alignEdges(others, key, { x: q0.x, y: q0.y, w: Math.max(MINW, q0.w + dx), h: Math.max(MINH, q0.h + dy) }, true);
|
||
let { w, h } = g;
|
||
if (snap) { w = Math.round(w / GRID) * GRID; h = Math.round(h / GRID) * GRID; }
|
||
const cc = clampToCol(lay.colBounds, key, r0.x, Math.max(MINW, w));
|
||
next = { ...r0, w: cc.w, h: Math.max(MINH, h), auto: false };
|
||
setGuides({ x: g.guideX, y: g.guideY });
|
||
} else {
|
||
const g = alignEdges(others, key, { x: Math.max(0, q0.x + dx), y: Math.max(0, q0.y + dy), w: q0.w, h: q0.h }, false);
|
||
let { x, y } = g;
|
||
if (snap && !g.snappedX) x = Math.round(x / GRID) * GRID;
|
||
if (snap && !g.snappedY) y = Math.round(y / GRID) * GRID;
|
||
const laneY = live.current.placed!.lanes[lay.laneOf[key]].y;
|
||
next = { ...r0, x: clampToCol(lay.colBounds, key, Math.max(0, x), r0.w).x, ry: Math.max(TOP, y - laneY) };
|
||
setGuides({ x: g.guideX, y: g.guideY });
|
||
}
|
||
cur = { ...cur, [key]: next };
|
||
setRel(cur);
|
||
};
|
||
const stop = () => {
|
||
el.classList.remove("drag");
|
||
el.removeEventListener("pointermove", move); el.removeEventListener("pointerup", stop); el.removeEventListener("pointercancel", stop);
|
||
setGuides(null);
|
||
saveLayout(cur, lay.colBounds, byDate);
|
||
};
|
||
el.addEventListener("pointermove", move); el.addEventListener("pointerup", stop); el.addEventListener("pointercancel", stop);
|
||
}, [snap, byDate]);
|
||
|
||
// двойной щелчок по уголку: ширина CW (в пределах колонки даты), высота по содержимому (строка 750)
|
||
const onAutoSize = useCallback((key: string) => {
|
||
const { layout: lay, rel: r } = live.current;
|
||
if (!lay) return;
|
||
const el = measureRef.current?.querySelector<HTMLElement>(`.card[data-key="${CSS.escape(key)}"]`);
|
||
const cc = clampToCol(lay.colBounds, key, r[key].x, CW);
|
||
const next = { ...r, [key]: { ...r[key], x: cc.x, w: cc.w, auto: true, h: measuredHeight(el?.offsetHeight ?? 0) } };
|
||
setRel(next);
|
||
saveLayout(next, lay.colBounds, byDate);
|
||
}, [byDate]);
|
||
|
||
// полоса дат и нижняя полоса прокрутки повторяют горизонтальную прокрутку доски (строки 705–714)
|
||
const syncScroll = useCallback(() => {
|
||
const w = wrapRef.current;
|
||
if (!w) return;
|
||
setScroll({ left: w.scrollLeft, wide: w.scrollWidth > w.clientWidth + 1, wrapW: w.offsetWidth, scrollW: w.scrollWidth });
|
||
const h = hscrollRef.current;
|
||
if (h && Math.abs(h.scrollLeft - w.scrollLeft) > 1) h.scrollLeft = w.scrollLeft;
|
||
}, []);
|
||
useLayoutEffect(() => {
|
||
syncScroll();
|
||
const w = wrapRef.current;
|
||
if (!w || typeof ResizeObserver === "undefined") return;
|
||
const ro = new ResizeObserver(syncScroll);
|
||
ro.observe(w);
|
||
return () => ro.disconnect();
|
||
}, [syncScroll, placed]);
|
||
|
||
const empty = !vis.nodes.length;
|
||
const multiSprint = layout?.multiSprint ?? false;
|
||
// слой измерения рисует метку спринта так же, как доска: она влияет на высоту карточки (строка 661)
|
||
const measureMulti = new Set(vis.nodes.filter((x) => !x.stub).map((x) => x.sprint)).size > 1;
|
||
const boardTop = () => boardRef.current?.getBoundingClientRect().top ?? 0;
|
||
const counts = Object.fromEntries(vis.lanes.map((l) => [l.name, l.keys.length]));
|
||
const byKey = Object.fromEntries(vis.nodes.map((n) => [n.key, n]));
|
||
|
||
return (
|
||
<>
|
||
{byDate && !empty && layout?.dateCols && placed && (
|
||
<DatesBar cols={layout.dateCols} width={placed.width} scrollLeft={scroll.left} inRef={datesInRef} onColWidth={onColWidth} />
|
||
)}
|
||
<div className={"stage" + (byDate && !empty ? " bydate" : "")}>
|
||
<div className="wrap" ref={wrapRef} onScroll={syncScroll}>
|
||
<div className={"board" + (byDate ? " bydate" : "")} ref={boardRef}
|
||
style={placed && !empty ? { width: placed.width, height: placed.height } : { width: "100%", height: "auto" }}>
|
||
{empty && <div className="empty">{emptyText}</div>}
|
||
{placed && !empty && (
|
||
<>
|
||
{layout?.dateCols?.map((c) => (
|
||
<div key={c.date} className={"dcol" + (c.cls ? " " + c.cls : "")} style={{ left: c.x0, width: c.w, height: placed.height }} />
|
||
))}
|
||
{placed.lanes.map((g, i) => (
|
||
<Lane key={g.name} g={g} index={i} last={placed.lanes.length - 1} count={counts[g.name] ?? 0} lanes={placed.lanes}
|
||
boardTop={boardTop} onToggle={onToggleLane} onMove={onMoveLane}
|
||
targetClass={dragTarget && dragTarget.to === i ? (i < dragTarget.from ? "target" : i > dragTarget.from ? "target-after" : "") : ""}
|
||
onDragTarget={(to) => setDragTarget(to === null ? null : { from: i, to })} />
|
||
))}
|
||
{Object.entries(placed.pos).map(([k, box]) => (
|
||
<Card key={k} node={byKey[k]} box={box} rel={rel[k]} multiSprint={multiSprint} sprintSel={sprintSel}
|
||
onPointerDown={onCardDown} onAutoSize={onAutoSize} />
|
||
))}
|
||
<Edges links={vis.links} placed={placed} />
|
||
<Guides x={guides?.x ?? null} y={guides?.y ?? null} width={placed.width} height={placed.height} />
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
{children}
|
||
</div>
|
||
<div className="hscroll" ref={hscrollRef} hidden={!scroll.wide}
|
||
style={{ width: scroll.wrapW }}
|
||
onScroll={(e) => { const w = wrapRef.current; if (w && Math.abs(e.currentTarget.scrollLeft - w.scrollLeft) > 1) w.scrollLeft = e.currentTarget.scrollLeft; }}>
|
||
<div style={{ width: scroll.scrollW, height: 1 }} />
|
||
</div>
|
||
<div className="measure" ref={measureRef} aria-hidden="true">
|
||
{vis.nodes.map((n) => <Card key={n.key} node={n} multiSprint={measureMulti} sprintSel={sprintSel} />)}
|
||
</div>
|
||
</>
|
||
);
|
||
}
|
||
```
|
||
|
||
Отступление от `links.html`, разрешённое этой задачей: автоподбор высоты меряет карточку при новой ширине (CW), а
|
||
старая страница — при прежней (порядок вызовов в строке 750) — рулинг R-10 в design.md.
|
||
|
||
- [ ] **Step 8: Прогнать**
|
||
|
||
Run: `cd web && npm run lint && npx vitest run src/board`
|
||
Expected: PASS — 7 тестов холста + прежние. В jsdom нет раскладки (`offsetHeight` = 0, высоты = `MINH`) — геометрия
|
||
проверяется golden-тестами Task 8 и e2e Task 15.
|
||
|
||
- [ ] **Step 9: Commit**
|
||
|
||
```bash
|
||
git add web/src/board/components web/src/board/board.css
|
||
git commit -m "feat(web): холст доски — дорожки, карточки, стрелки, перетаскивание, колонки дат (#21)
|
||
|
||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
### Task 12: страница «Доска» — панель управления, фильтры, легенда, проблемы, таблица, экспорт
|
||
|
||
**Модель:** sonnet.
|
||
|
||
**Files:**
|
||
- Create: `web/src/board/useBoardPrefs.ts`, `web/src/board/components/Toolbar.tsx`,
|
||
`web/src/board/components/Dropdown.tsx`, `web/src/board/components/MultiSelect.tsx`,
|
||
`web/src/board/components/FilterBar.tsx`, `web/src/board/components/Legend.tsx`,
|
||
`web/src/board/components/ProblemsDrawer.tsx`, `web/src/board/components/LinkTable.tsx`,
|
||
`web/src/board/components/ExportBox.tsx`
|
||
- Modify: `web/src/board/BoardPage.tsx` (заменяет временную страницу Task 4), `web/src/board/components/BoardCanvas.tsx`
|
||
(свойство `saveRef`), `web/src/board/model/util.ts` (`todayIso`)
|
||
- Test: `web/src/board/BoardPage.test.tsx`
|
||
|
||
**Interfaces:**
|
||
- Consumes: всё из Task 6–11; `useSettings` (Task 3); `THEMES`, `THEME_NAMES`, `readTheme`, `saveTheme`, `applyTheme`
|
||
(Task 4).
|
||
- Produces: `BoardPage` (страница `/app/board`, `data-testid="page-board"`); `useBoardPrefs(model)`:
|
||
|
||
```ts
|
||
export interface BoardPrefsApi {
|
||
filters: Filters; snap: boolean; table: boolean; problems: boolean; store: LayoutStore;
|
||
toggle(name: FlagName | "snap" | "table" | "problems"): void;
|
||
setSprints(s: Set<string>): void;
|
||
setWho(s: Set<string>): void;
|
||
toggleHidden(what: "kind" | "edge", value: string): void;
|
||
toggleLane(name: string): void;
|
||
moveLane(current: string[], name: string, to: number): void;
|
||
setColWidth(date: string, w: number | null): void;
|
||
resetLayout(): void;
|
||
applyJson(r: JsonImport): void;
|
||
}
|
||
```
|
||
|
||
`BoardCanvas` получает необязательное свойство `saveRef?: { current: (() => void) | null }`: в `useLayoutEffect`
|
||
холст кладёт туда `() => { const { rel, layout } = live.current; if (layout) saveLayout(rel, layout.colBounds, byDate); }`.
|
||
Экспорт JSON вызывает его перед `exportJson` (как `saveLayout()` в строке 1173): так в выгрузку попадает и
|
||
автоматическая раскладка карточек, которые не двигали.
|
||
|
||
- [ ] **Step 1: Падающий тест страницы** — `web/src/board/BoardPage.test.tsx`:
|
||
|
||
```tsx
|
||
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
|
||
import userEvent from "@testing-library/user-event";
|
||
import { readFileSync } from "node:fs";
|
||
import { beforeEach, describe, expect, it } from "vitest";
|
||
import { setToken } from "../auth/token.ts";
|
||
import { json, mockFetch } from "../test/fetchMock.ts";
|
||
import { renderWithProviders } from "../test/render.tsx";
|
||
import settings from "./__fixtures__/settings.json";
|
||
import { BoardPage } from "./BoardPage.tsx";
|
||
|
||
const fx = (name: string) => readFileSync(new URL(`./__fixtures__/${name}`, import.meta.url), "utf8");
|
||
const SNAP = JSON.parse(fx("hub-basic.board.json"));
|
||
|
||
function hub(over: Record<string, () => Response> = {}) {
|
||
return mockFetch((method, url) => {
|
||
const k = `${method} ${url.pathname}`;
|
||
if (over[k]) return over[k]();
|
||
if (k === "GET /settings") return json(200, settings);
|
||
if (k === "GET /board") return json(200, SNAP);
|
||
if (k === "GET /batches") return json(200, { batches: [] });
|
||
if (k === "GET /agent") return json(200, { online: true });
|
||
if (k === "POST /refresh") return json(201, { id: 7, estimate_seconds: 5, count: 1 });
|
||
if (k === "GET /batches/7") return json(200, { id: 7, author: "viewer", status: "running", remaining_seconds: 3, counts: {} });
|
||
return json(404, { error: "not_found" });
|
||
});
|
||
}
|
||
const page = () => renderWithProviders(<BoardPage />, { route: "/board", path: "/board" });
|
||
|
||
describe("страница доски", () => {
|
||
beforeEach(() => setToken("tok"));
|
||
|
||
it("строит доску из хаба: источник, статистика, выбор самого наполненного спринта", async () => {
|
||
hub();
|
||
page();
|
||
expect(await screen.findByText(/^Хаб · данные на/)).toBeInTheDocument();
|
||
expect(screen.getByText(/задач из 9/)).toBeInTheDocument();
|
||
expect(screen.getByRole("button", { name: /AWPCA_Спринт 2/ })).toBeInTheDocument();
|
||
});
|
||
|
||
it("пустые словари в настройках — сообщение, а не пустая доска", async () => {
|
||
hub({ "GET /settings": () => json(200, { kind_labels: {}, link_types: {} }) });
|
||
page();
|
||
expect(await screen.findByText(/В настройках хаба нет kind_labels и link_types/)).toBeInTheDocument();
|
||
expect(document.querySelector(".board")).toBeNull();
|
||
});
|
||
|
||
it("переключатели и легенда сохраняются под прежними ключами", async () => {
|
||
hub();
|
||
page();
|
||
await screen.findByText(/задач из 9/);
|
||
const nb = screen.getByRole("button", { name: "+ соседи по связям" });
|
||
await userEvent.click(nb);
|
||
expect(nb).toHaveAttribute("aria-pressed", "true");
|
||
expect(localStorage.getItem("graph-neighbors")).toBe("1");
|
||
await userEvent.click(within(document.querySelector(".legend") as HTMLElement).getByRole("button", { name: /разработка/ }));
|
||
expect(localStorage.getItem("graph-hidekind")).toBe('["dev"]');
|
||
});
|
||
|
||
it("«Проблемы» открывают ящик, счётчик — сроки, исполнители, статусы", async () => {
|
||
hub();
|
||
page();
|
||
await screen.findByText(/задач из 9/);
|
||
await userEvent.click(screen.getByRole("button", { name: /Проблемы/ }));
|
||
expect(screen.getByText("Проблемы на доске")).toBeInTheDocument();
|
||
expect(localStorage.getItem("graph-prob")).toBe("1");
|
||
});
|
||
|
||
it("экспорт JSON показывает выгрузку arm-ka-links/1 с раскладкой", async () => {
|
||
hub();
|
||
page();
|
||
await screen.findByText(/задач из 9/);
|
||
await userEvent.click(screen.getByTitle("Ещё"));
|
||
await userEvent.click(screen.getByRole("button", { name: "Экспорт JSON" }));
|
||
const text = (screen.getByRole("textbox") as HTMLTextAreaElement).value;
|
||
expect(JSON.parse(text)).toMatchObject({ format: "arm-ka-links/1", source: "Хаб" });
|
||
expect(JSON.parse(text).nodes.find((n: { key: string }) => n.key === "AURORA-9001").layout).not.toBeNull();
|
||
});
|
||
|
||
it("сброс раскладки — с подтверждением в меню", async () => {
|
||
hub();
|
||
localStorage.setItem("graph-pos:board", '{"AURORA-9001":{"x":1,"ry":40}}');
|
||
page();
|
||
await screen.findByText(/задач из 9/);
|
||
await userEvent.click(screen.getByTitle("Ещё"));
|
||
await userEvent.click(screen.getByRole("button", { name: "Сбросить раскладку" }));
|
||
await userEvent.click(screen.getByRole("button", { name: "Да, сбросить" }));
|
||
expect(localStorage.getItem("graph-pos:board")).toBeNull();
|
||
});
|
||
|
||
it("загрузка CSV — источник «из файла»", async () => {
|
||
hub();
|
||
page();
|
||
await screen.findByText(/задач из 9/);
|
||
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||
fireEvent.change(input, { target: { files: [new File([fx("jira-export.csv")], "jira-export.csv", { type: "text/csv" })] } });
|
||
expect(await screen.findByText("jira-export.csv (из файла)")).toBeInTheDocument();
|
||
});
|
||
|
||
it("тема переключается по кругу и применяется к документу", async () => {
|
||
hub();
|
||
page();
|
||
await screen.findByText(/задач из 9/);
|
||
await userEvent.click(screen.getByTitle("Ещё"));
|
||
await userEvent.click(screen.getByRole("button", { name: "Тема: системная" }));
|
||
expect(document.documentElement.getAttribute("data-theme")).toBe("light");
|
||
expect(localStorage.getItem("graph-theme")).toBe("light");
|
||
});
|
||
|
||
it("«Обновить из Jira» ставит пакет и недоступна, пока он идёт", async () => {
|
||
const fetch = hub();
|
||
page();
|
||
await screen.findByText(/задач из 9/);
|
||
const btn = screen.getByRole("button", { name: "Обновить из Jira" });
|
||
await userEvent.click(btn);
|
||
await waitFor(() => expect(btn).toBeDisabled());
|
||
expect(fetch.mock.calls.filter(([u, i]) => u === "/refresh" && i?.method === "POST")).toHaveLength(1);
|
||
expect(await screen.findByText(/пакет #7 выполняется/)).toBeInTheDocument();
|
||
});
|
||
});
|
||
```
|
||
|
||
Run: `cd web && npx vitest run src/board/BoardPage.test.tsx` → FAIL.
|
||
|
||
- [ ] **Step 2: `useBoardPrefs.ts`**
|
||
|
||
```ts
|
||
import { useCallback, useMemo, useState } from "react";
|
||
import { initialSprints, initialWho } from "./analysis/facets.ts";
|
||
import type { LayoutStore } from "./components/BoardCanvas.tsx";
|
||
import * as st from "./storage.ts";
|
||
import type { BoardModel, Filters, JsonImport } from "./types.ts";
|
||
|
||
export interface BoardPrefsApi {
|
||
filters: Filters; snap: boolean; table: boolean; problems: boolean; store: LayoutStore;
|
||
toggle(name: st.FlagName | "snap" | "table" | "problems"): void;
|
||
setSprints(s: Set<string>): void;
|
||
setWho(s: Set<string>): void;
|
||
toggleHidden(what: "kind" | "edge", value: string): void;
|
||
toggleLane(name: string): void;
|
||
moveLane(current: string[], name: string, to: number): void;
|
||
setColWidth(date: string, w: number | null): void;
|
||
resetLayout(): void;
|
||
applyJson(r: JsonImport): void;
|
||
}
|
||
|
||
const readStore = (version: number): LayoutStore =>
|
||
({ laneOrder: st.laneOrder(), folded: st.foldedSet(), colWidths: st.colWidths(), version });
|
||
const pick = (model: BoardModel, f: Filters) =>
|
||
({ model, sprints: initialSprints(model, f, st.rememberedSprints()), who: initialWho(model, f, st.rememberedWho()) });
|
||
|
||
// настройки доски из хранилища links.html: флаги, скрытые виды и типы, выбор спринтов и исполнителей, раскладка
|
||
export function useBoardPrefs(model: BoardModel): BoardPrefsApi {
|
||
const [prefs, setPrefs] = useState<st.BoardPrefs>(() => { st.migrateMode(); st.migrateStore(); return st.readPrefs(); });
|
||
const [store, setStore] = useState<LayoutStore>(() => readStore(0));
|
||
const [sel, setSel] = useState(() => pick(model, prefs.filters));
|
||
if (sel.model !== model) setSel(pick(model, prefs.filters)); // новая модель — выбор заново, как fillSprints/fillWho
|
||
const bump = useCallback(() => setStore((s) => readStore(s.version + 1)), []);
|
||
|
||
const filters = useMemo<Filters>(() => ({ ...prefs.filters, sprints: sel.sprints, who: sel.who }), [prefs.filters, sel]);
|
||
|
||
const toggle = useCallback((name: st.FlagName | "snap" | "table" | "problems") => {
|
||
setPrefs((p) => {
|
||
if (name === "snap" || name === "table" || name === "problems") { st.writeFlag(name, !p[name]); return { ...p, [name]: !p[name] }; }
|
||
st.writeFlag(name, !p.filters[name]);
|
||
return { ...p, filters: { ...p.filters, [name]: !p.filters[name] } };
|
||
});
|
||
}, []);
|
||
const setSprints = useCallback((s: Set<string>) => { st.writeSprints(s); setSel((x) => ({ ...x, sprints: new Set(s) })); }, []);
|
||
const setWho = useCallback((s: Set<string>) => { st.writeWho(s); setSel((x) => ({ ...x, who: new Set(s) })); }, []);
|
||
const toggleHidden = useCallback((what: "kind" | "edge", value: string) => {
|
||
setPrefs((p) => {
|
||
const key = what === "kind" ? "hideKind" : "hideEdge";
|
||
const next = new Set(p.filters[key]);
|
||
if (next.has(value)) next.delete(value); else next.add(value);
|
||
const filters = { ...p.filters, [key]: next };
|
||
st.writeHidden(filters.hideKind, filters.hideEdge);
|
||
return { ...p, filters };
|
||
});
|
||
}, []);
|
||
const toggleLane = useCallback((name: string) => { st.toggleLane(name); bump(); }, [bump]);
|
||
const moveLane = useCallback((current: string[], name: string, to: number) => { if (st.moveLane(current, name, to)) bump(); }, [bump]);
|
||
const setColWidth = useCallback((date: string, w: number | null) => { st.setColWidth(date, w); bump(); }, [bump]);
|
||
const resetLayout = useCallback(() => { st.resetLayout(prefs.filters.byDate); bump(); }, [prefs.filters.byDate, bump]);
|
||
const applyJson = useCallback((r: JsonImport) => {
|
||
const patch = st.applyJsonSettings(r);
|
||
setPrefs((p) => ({ ...p, filters: { ...p.filters, ...patch } }));
|
||
setSel(pick(model, { ...prefs.filters, ...patch }));
|
||
bump();
|
||
}, [model, prefs.filters, bump]);
|
||
|
||
return { filters, snap: prefs.snap, table: prefs.table, problems: prefs.problems, store,
|
||
toggle, setSprints, setWho, toggleHidden, toggleLane, moveLane, setColWidth, resetLayout, applyJson };
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Выпадающие списки** (строки 897–938)
|
||
|
||
`web/src/board/components/Dropdown.tsx`:
|
||
|
||
```tsx
|
||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||
|
||
// выпадающий список: кнопка открывает, щелчок вне или Escape закрывает (строки 897–904)
|
||
export function Dropdown({ button, className = "", children, onOpen }: {
|
||
button: (p: { open: boolean; toggle(): void }) => ReactNode; className?: string;
|
||
children: (close: () => void) => ReactNode; onOpen?: () => void;
|
||
}) {
|
||
const [open, setOpen] = useState(false);
|
||
const ref = useRef<HTMLDivElement>(null);
|
||
useEffect(() => {
|
||
if (!open) return;
|
||
const away = (e: PointerEvent) => { if (!ref.current?.contains(e.target as Node)) setOpen(false); };
|
||
document.addEventListener("pointerdown", away);
|
||
return () => document.removeEventListener("pointerdown", away);
|
||
}, [open]);
|
||
const toggle = () => setOpen((o) => { if (!o) onOpen?.(); return !o; });
|
||
return (
|
||
<div className={"dd " + className} ref={ref} onKeyDown={(e) => { if (e.key === "Escape") setOpen(false); }}>
|
||
{button({ open, toggle })}
|
||
{open && <div className={"dd-menu" + (className.includes("more") ? " menu" : "")}>{children(() => setOpen(false))}</div>}
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
`web/src/board/components/MultiSelect.tsx`:
|
||
|
||
```tsx
|
||
import { useState } from "react";
|
||
import type { FacetItem } from "../analysis/facets.ts";
|
||
import { Dropdown } from "./Dropdown.tsx";
|
||
|
||
// мультиселект с поиском: пустой выбор = без фильтра (строки 913–938)
|
||
export function MultiSelect({ items, selected, allLabel, onChange }: {
|
||
items: FacetItem[]; selected: Set<string>; allLabel: string; onChange(next: Set<string>): void;
|
||
}) {
|
||
const [q, setQ] = useState("");
|
||
const sel = items.filter((i) => selected.has(i.value));
|
||
const label = !sel.length ? allLabel : sel.length === 1 ? sel[0].label : `${sel[0].label} +${sel.length - 1}`;
|
||
const shown = items.filter((i) => !q || i.label.toLowerCase().includes(q.toLowerCase()) || i.value.toLowerCase().includes(q.toLowerCase()));
|
||
const flip = (v: string, on: boolean) => { const n = new Set(selected); if (on) n.add(v); else n.delete(v); onChange(n); };
|
||
return (
|
||
<Dropdown onOpen={() => setQ("")} button={({ open, toggle }) => (
|
||
<button type="button" className="btn dd-btn" aria-haspopup="listbox" aria-expanded={open} title={sel.map((i) => i.label).join(", ")} onClick={toggle}>
|
||
<span className="lbl">{label}</span><span className="car">▼</span>
|
||
</button>
|
||
)}>
|
||
{() => (
|
||
<>
|
||
<input type="search" placeholder="Поиск…" aria-label="Поиск" autoFocus value={q} onChange={(e) => setQ(e.target.value)} />
|
||
<div className="dd-list" role="listbox">
|
||
{!shown.length && <div className="dd-empty">ничего не найдено</div>}
|
||
{shown.map((i) => (
|
||
<label key={i.value} className={"dd-item" + (i.cls ? " " + i.cls : "")} title={i.title || i.label}>
|
||
<input type="checkbox" checked={selected.has(i.value)} onChange={(e) => flip(i.value, e.target.checked)} />
|
||
<span>{i.label}</span><span className="n">{i.count}</span>
|
||
</label>
|
||
))}
|
||
</div>
|
||
<div className="dd-foot">
|
||
<button type="button" className="btn all" onClick={() => onChange(new Set(items.map((i) => i.value)))}>Выбрать все</button>
|
||
<button type="button" className="btn clear" onClick={() => onChange(new Set())}>Снять выбор</button>
|
||
</div>
|
||
</>
|
||
)}
|
||
</Dropdown>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Панели**
|
||
|
||
`web/src/board/components/FilterBar.tsx` (строки 251–258, 951–983):
|
||
|
||
```tsx
|
||
import { sprintBox, sprintItems, whoBox, whoItems } from "../analysis/facets.ts";
|
||
import type { BoardPrefsApi } from "../useBoardPrefs.ts";
|
||
import type { BoardModel } from "../types.ts";
|
||
import { MultiSelect } from "./MultiSelect.tsx";
|
||
|
||
const TOGGLES = [
|
||
{ name: "neighbors", label: "+ соседи по связям", title: "Добавить к отобранным задачам их соседей по связям из любого спринта, любого исполнителя и вида работы. Соседи рисуются пунктиром и считаются отдельно" },
|
||
{ name: "onlyHand", label: "⇄ передачи", title: "Оставить только стрелки, где задачу ждёт другой человек. Карточки не убирает — для этого «скрыть без связей»" },
|
||
{ name: "onlyLinked", label: "скрыть без связей", title: "Убрать с доски карточки, у которых не осталось ни одной видимой стрелки" },
|
||
{ name: "crit", label: "⚑ критический путь", title: "Самая длинная цепочка блокировок по плановым датам окончания" },
|
||
{ name: "byDate", label: "▥ по датам", title: "Разложить карточки по колонкам плановых дат окончания. У этого режима своя расстановка: карточку можно двигать внутри её колонки; обычная расстановка не меняется и вернётся, когда режим выключен" },
|
||
] as const;
|
||
|
||
export function FilterBar({ model, prefs }: { model: BoardModel; prefs: BoardPrefsApi }) {
|
||
const f = prefs.filters, sb = sprintBox(model), wb = whoBox(model, f);
|
||
return (
|
||
<div className="bar">
|
||
<span>{sb.kind === "text" ? <span className="file-name">{sb.text}</span>
|
||
: sb.kind === "multi" && <MultiSelect items={sprintItems(model, f).items} selected={f.sprints} allLabel="Все спринты" onChange={prefs.setSprints} />}</span>
|
||
<span>{wb.kind === "text" ? <span className="file-name">{wb.text}</span>
|
||
: wb.kind === "multi" && <MultiSelect items={whoItems(model, f).items} selected={f.who} allLabel="Все исполнители" onChange={prefs.setWho} />}</span>
|
||
{TOGGLES.map((t) => (
|
||
<button key={t.name} className="btn" type="button" aria-pressed={f[t.name]} title={t.title} onClick={() => prefs.toggle(t.name)}>{t.label}</button>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
`web/src/board/components/Legend.tsx` (строки 265–279, 985–996):
|
||
|
||
```tsx
|
||
import type { ReactNode } from "react";
|
||
import type { BoardPrefsApi } from "../useBoardPrefs.ts";
|
||
|
||
const KINDS = [["decision", "решение"], ["analytics", "аналитика / актуализация"], ["dev", "разработка"], ["ui", "UI"],
|
||
["test", "тестирование"], ["org", "организационная"]] as const;
|
||
const EDGES = [["block", "блокирует начало → заблокирована", "ln"], ["rel", "связанные", "ln dash"]] as const;
|
||
|
||
export function Legend({ prefs }: { prefs: BoardPrefsApi }) {
|
||
const { hideKind, hideEdge } = prefs.filters;
|
||
const btn = (what: "kind" | "edge", value: string, off: boolean, body: ReactNode) => (
|
||
<button key={what + value} type="button" className={"lg" + (off ? " off" : "")} aria-pressed={!off}
|
||
title={off ? "Показать снова" : "Скрыть на доске"} onClick={() => prefs.toggleHidden(what, value)}>{body}</button>
|
||
);
|
||
return (
|
||
<div className="legend">
|
||
{KINDS.map(([k, label]) => btn("kind", k, hideKind.has(k), <><span className="sw" style={{ background: `var(--k-${k})` }} />{label}</>))}
|
||
<span className="sep" />
|
||
{EDGES.map(([k, label, ln]) => btn("edge", k, hideEdge.has(k), <><span className={ln} />{label}</>))}
|
||
<span><span className="ln hand" />передача другому исполнителю</span>
|
||
<span><span className="ln crit" />критический путь</span>
|
||
<span><span className="out-sw" />сосед по связям, сам под фильтры не попадает</span>
|
||
<span className="hint">щелчок по виду работы или типу связи скрывает их</span>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
`web/src/board/components/ProblemsDrawer.tsx` (строки 300, 882–891):
|
||
|
||
```tsx
|
||
import { JIRA_URL } from "../constants.ts";
|
||
import type { ProblemItem, Problems } from "../types.ts";
|
||
|
||
export const problemCount = (p: Problems) => p.dates.length + p.unassigned.length + p.late.length;
|
||
|
||
const K = ({ k }: { k: string }) => <a className="k" href={`${JIRA_URL}/browse/${k}`} target="_blank" rel="noopener">{k}</a>;
|
||
function Section({ title, list }: { title: string; list: ProblemItem[] }) {
|
||
if (!list.length) return null;
|
||
return (
|
||
<>
|
||
<h4>{title} ({list.length})</h4>
|
||
<ul>{list.map((x, i) => <li key={i}><K k={x.a.key} /> → <K k={x.b.key} /> <span className="why">— {x.text}</span></li>)}</ul>
|
||
</>
|
||
);
|
||
}
|
||
|
||
export function ProblemsDrawer({ problems, onClose }: { problems: Problems; onClose(): void }) {
|
||
return (
|
||
<aside className="drawer">
|
||
<div className="drawer-head"><b>Проблемы на доске</b><button type="button" className="btn sm" onClick={onClose}>Закрыть</button></div>
|
||
<div>
|
||
{!problemCount(problems) && <div className="ok">По датам, исполнителям и статусам блокеров на видимой части доски проблем нет.</div>}
|
||
<Section title="Срок блокера не раньше срока зависимой задачи" list={problems.dates} />
|
||
<Section title="Блокер без исполнителя" list={problems.unassigned} />
|
||
<Section title="Блокер не начат, а срок зависимой задачи близко" list={problems.late} />
|
||
<Section title="Передачи между исполнителями (точки ожидания)" list={problems.hand} />
|
||
</div>
|
||
</aside>
|
||
);
|
||
}
|
||
```
|
||
|
||
`web/src/board/components/LinkTable.tsx` (строки 302, 892–894):
|
||
|
||
```tsx
|
||
import type { VisLink } from "../types.ts";
|
||
|
||
export function LinkTable({ links }: { links: VisLink[] }) {
|
||
return (
|
||
<details open>
|
||
<summary>Таблица связей</summary>
|
||
<div className="tbl"><table>
|
||
<thead><tr><th>Блокирует</th><th>Тип</th><th>Заблокирована</th></tr></thead>
|
||
<tbody>{links.map((l) => <tr key={`${l.src}>${l.dst}${l.type}`}><td className="k">{l.src}</td><td>{l.type}</td><td className="k">{l.dst}</td></tr>)}</tbody>
|
||
</table></div>
|
||
</details>
|
||
);
|
||
}
|
||
```
|
||
|
||
`web/src/board/components/ExportBox.tsx` (строки 294–297, 1195–1200):
|
||
|
||
```tsx
|
||
import { useState } from "react";
|
||
|
||
export function ExportBox({ text, onClose }: { text: string; onClose(): void }) {
|
||
const [copied, setCopied] = useState(false);
|
||
const copy = async (ta: HTMLTextAreaElement | null) => {
|
||
try { await navigator.clipboard.writeText(text); setCopied(true); setTimeout(() => setCopied(false), 1500); }
|
||
catch { ta?.focus(); ta?.select(); }
|
||
};
|
||
let ta: HTMLTextAreaElement | null = null;
|
||
return (
|
||
<div className="export">
|
||
<div className="row">
|
||
<span className="hint">JSON доски: все задачи, связи, раскладка, дорожки и фильтры. Загружается обратно ссылкой «файл…». Если файл не скачался, скопируйте текст.</span>
|
||
<button className="btn" type="button" onClick={() => void copy(ta)}>{copied ? "Скопировано" : "Копировать"}</button>
|
||
<button className="btn" type="button" onClick={onClose}>Закрыть</button>
|
||
</div>
|
||
<textarea readOnly spellCheck={false} value={text} ref={(el) => { ta = el; }} />
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
`web/src/board/components/Toolbar.tsx` (строки 241–263, 870–880, 998–1015, 1159–1169, 1185–1194):
|
||
|
||
```tsx
|
||
import { useEffect, useState } from "react";
|
||
import { applyTheme, readTheme, saveTheme, THEME_NAMES, THEMES, type Theme } from "../../shell/theme.ts";
|
||
import { statusText, type BoardStatus } from "../status.ts";
|
||
import type { BoardModel, Visible } from "../types.ts";
|
||
import { NOT_IN_HUB } from "../constants.ts";
|
||
import { Dropdown } from "./Dropdown.tsx";
|
||
|
||
interface ToolbarProps {
|
||
label: string; title: string; status: BoardStatus | null; refreshing: boolean; byDate: boolean; snap: boolean;
|
||
model: BoardModel; vis: Visible; problemCount: number; problemsOpen: boolean; tableOpen: boolean;
|
||
onReload(): void; onRefresh(): void; onFile(name: string, text: string): void; onProblems(): void;
|
||
onExport(): void; onTable(): void; onSnap(): void; onReset(): void;
|
||
}
|
||
|
||
export function Toolbar(p: ToolbarProps) {
|
||
const [theme, setTheme] = useState<Theme>(readTheme);
|
||
const [confirm, setConfirm] = useState(false);
|
||
useEffect(() => { if (!confirm) return; const t = setTimeout(() => setConfirm(false), 8000); return () => clearTimeout(t); }, [confirm]);
|
||
const st = p.status ? statusText(p.status) : null;
|
||
const m = p.model, v = p.vis, r = v.removed;
|
||
const coreShown = v.nodes.filter((n) => !n.dim).length, ctxShown = v.nodes.length - coreShown;
|
||
const nextTheme = () => { const t = THEMES[(THEMES.indexOf(theme) + 1) % THEMES.length]; setTheme(t); saveTheme(t); applyTheme(t); };
|
||
return (
|
||
<div className="top">
|
||
<span className="src">
|
||
<span className="file-name" title={p.title}>{p.label}</span>
|
||
<button type="button" className="link" title="Загрузить снимок зеркала хаба заново, без обращения к Jira" onClick={p.onReload}>Перечитать</button>
|
||
<button type="button" className="link" disabled={p.refreshing} onClick={p.onRefresh}
|
||
title="Поставить в хаб пакет «обновление по базовой JQL» и дождаться его: агент перечитает задачи из Jira">Обновить из Jira</button>
|
||
<label className="link" title="CSV из фильтра Jira или JSON, выгруженный этой доской. JSON при работе с хабом переносит только расстановку карточек, дорожки и фильтры; CSV показывает снимок файла до «Перечитать»">
|
||
файл…
|
||
<input type="file" accept=".csv,.json,text/csv,application/json" hidden onChange={async (e) => {
|
||
const f = e.target.files?.[0];
|
||
e.target.value = "";
|
||
if (f) p.onFile(f.name, await f.text());
|
||
}} />
|
||
</label>
|
||
</span>
|
||
{st && <span className={"stats" + (st.warn ? " warn" : "")} role="status">{st.text}</span>}
|
||
<span className="stats">
|
||
<b>{coreShown}</b> задач из {m.nodes.filter((n) => !n.stub).length}
|
||
{ctxShown > 0 && <> · <b>+{ctxShown}</b> соседи по связям</>}
|
||
{" · "}<b>{v.links.length}</b> связей из {m.links.length}
|
||
{r.loose > 0 && <> · скрыто без связей: {r.loose}</>}
|
||
</span>
|
||
<span className="spacer" />
|
||
<button className="btn" type="button" aria-pressed={p.problemsOpen} onClick={p.onProblems}
|
||
title="Сроки блокеров, задачи без исполнителя, передачи между людьми">
|
||
Проблемы <span className={"n" + (p.problemCount ? "" : " zero")}>{p.problemCount}</span>
|
||
</button>
|
||
<Dropdown className="more" button={({ open, toggle }) => (
|
||
<button type="button" className="btn more-btn" aria-haspopup="menu" aria-expanded={open} title="Ещё" onClick={toggle}>⋯</button>
|
||
)}>
|
||
{(close) => (
|
||
<>
|
||
<button type="button" onClick={() => { close(); p.onExport(); }}>Экспорт JSON</button>
|
||
<button type="button" aria-pressed={p.tableOpen} onClick={p.onTable}>Таблица связей</button>
|
||
<button type="button" aria-pressed={p.snap} onClick={p.onSnap}>Привязка к сетке</button>
|
||
<button type="button" onClick={nextTheme}>Тема: {THEME_NAMES[theme]}</button>
|
||
<hr />
|
||
{!confirm ? <button type="button" className="danger" onClick={() => setConfirm(true)}>Сбросить раскладку</button> : (
|
||
<div className="confirm">
|
||
<span>Вернуть автоматическую раскладку {p.byDate ? "режима «по датам»" : "(обычную; раскладка «по датам» останется)"}, порядок и раскрытие дорожек?</span>
|
||
<div className="row">
|
||
<button type="button" className="btn yes" onClick={() => { p.onReset(); setConfirm(false); close(); }}>Да, сбросить</button>
|
||
<button type="button" className="btn no" onClick={() => setConfirm(false)}>Нет</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</Dropdown>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// предупреждения о данных под шапкой (строки 878–880)
|
||
export function Notice({ model }: { model: BoardModel }) {
|
||
const notes = [...model.problems];
|
||
if (model.missing) notes.push(`упомянуты в связях, но в ${model.stubLane === NOT_IN_HUB ? "зеркале хаба" : "файле"} отсутствуют: ${model.missing}`);
|
||
if (!notes.length) return null;
|
||
return <div className="notice">{notes.map((n) => <span key={n}>{n}</span>)}</div>;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: `BoardPage.tsx`** (заменить временную страницу):
|
||
|
||
```tsx
|
||
import { useLayoutEffect, useMemo, useRef, useState } from "react";
|
||
import { useSettings } from "../api/queries.ts";
|
||
import { computeVisible, emptyReason } from "./analysis/visible.ts";
|
||
import { BoardCanvas } from "./components/BoardCanvas.tsx";
|
||
import { ExportBox } from "./components/ExportBox.tsx";
|
||
import { FilterBar } from "./components/FilterBar.tsx";
|
||
import { Legend } from "./components/Legend.tsx";
|
||
import { LinkTable } from "./components/LinkTable.tsx";
|
||
import { problemCount, ProblemsDrawer } from "./components/ProblemsDrawer.tsx";
|
||
import { Notice, Toolbar } from "./components/Toolbar.tsx";
|
||
import { dictsFromSettings } from "./dicts.ts";
|
||
import { exportFileName, exportJson } from "./exportJson.ts";
|
||
import { todayIso } from "./model/util.ts";
|
||
import { useBoardPrefs } from "./useBoardPrefs.ts";
|
||
import { useBoardSource } from "./useBoardSource.ts";
|
||
import "./board.css";
|
||
|
||
export function BoardPage() {
|
||
const settings = useSettings();
|
||
const d = useMemo(() => (settings.data ? dictsFromSettings(settings.data) : null), [settings.data]);
|
||
const src = useBoardSource(d && !d.missing.length ? d.dicts : null);
|
||
const prefs = useBoardPrefs(src.model);
|
||
const today = todayIso();
|
||
const vis = useMemo(() => computeVisible(src.model, prefs.filters, prefs.store.laneOrder, today),
|
||
[src.model, prefs.filters, prefs.store.laneOrder, today]);
|
||
const [exportText, setExportText] = useState<string | null>(null);
|
||
const saveRef = useRef<(() => void) | null>(null);
|
||
const headRef = useRef<HTMLDivElement>(null);
|
||
|
||
// высота закреплённой шапки — ящик «Проблемы» и полоса дат липнут под ней (строки 1016–1019)
|
||
useLayoutEffect(() => {
|
||
const el = headRef.current;
|
||
if (!el) return;
|
||
const set = () => document.documentElement.style.setProperty("--head-h", (getComputedStyle(el).position === "sticky" ? el.offsetHeight : 0) + "px");
|
||
set();
|
||
if (typeof ResizeObserver === "undefined") return;
|
||
const ro = new ResizeObserver(set);
|
||
ro.observe(el);
|
||
return () => ro.disconnect();
|
||
});
|
||
|
||
if (settings.isPending) return <p className="hint" data-testid="page-board">Загрузка настроек хаба…</p>;
|
||
if (settings.error) return <p className="warn" data-testid="page-board">Настройки хаба не загрузились: {settings.error.message}</p>;
|
||
if (d && d.missing.length) {
|
||
return (
|
||
<div className="notice" role="alert" data-testid="page-board">
|
||
В настройках хаба нет {d.missing.join(" и ")} — доска не может перевести виды работ и типы связей Jira. Задайте
|
||
словари в config.example.json и выполните <code>uv run jira-hub init --config …</code> или запишите их через PUT /settings.
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const onExport = () => {
|
||
saveRef.current?.();
|
||
const now = new Date().toISOString();
|
||
const text = JSON.stringify(exportJson(src.model, vis, prefs.filters, now), null, 2);
|
||
setExportText(text);
|
||
try { // в обычном браузере файл скачается; если нет — остаются текст и «Копировать»
|
||
const a = document.createElement("a");
|
||
a.href = URL.createObjectURL(new Blob([text], { type: "application/json" }));
|
||
a.download = exportFileName(src.model, now);
|
||
document.body.appendChild(a); a.click();
|
||
setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 1000);
|
||
} catch { /* остаётся текст */ }
|
||
};
|
||
const onFile = (name: string, text: string) => { const r = src.loadFile(name, text); if (r) prefs.applyJson(r); };
|
||
|
||
return (
|
||
<div data-testid="page-board">
|
||
<div className="sticky" ref={headRef}>
|
||
<Toolbar label={src.label} title={src.title} status={src.status} refreshing={src.refreshing} byDate={prefs.filters.byDate}
|
||
snap={prefs.snap} model={src.model} vis={vis} problemCount={problemCount(vis.problems)} problemsOpen={prefs.problems}
|
||
tableOpen={prefs.table} onReload={src.reload} onRefresh={() => void src.refreshFromJira()} onFile={onFile}
|
||
onProblems={() => prefs.toggle("problems")} onExport={onExport} onTable={() => prefs.toggle("table")}
|
||
onSnap={() => prefs.toggle("snap")} onReset={prefs.resetLayout} />
|
||
<FilterBar model={src.model} prefs={prefs} />
|
||
<Notice model={src.model} />
|
||
<Legend prefs={prefs} />
|
||
</div>
|
||
{exportText !== null && <ExportBox text={exportText} onClose={() => setExportText(null)} />}
|
||
<BoardCanvas vis={vis} byDate={prefs.filters.byDate} snap={prefs.snap} today={today} store={prefs.store}
|
||
sprintSel={prefs.filters.sprints} emptyText={vis.nodes.length ? "" : emptyReason(src.model, vis, prefs.filters)}
|
||
onToggleLane={prefs.toggleLane} onMoveLane={(name, to) => prefs.moveLane(vis.lanes.map((l) => l.name), name, to)}
|
||
onColWidth={prefs.setColWidth} saveRef={saveRef}>
|
||
{prefs.problems && <ProblemsDrawer problems={vis.problems} onClose={() => prefs.toggle("problems")} />}
|
||
</BoardCanvas>
|
||
{prefs.table && <LinkTable links={vis.links} />}
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
`model/util.ts` — добавить `export const todayIso = (): string => new Date().toISOString().slice(0, 10);`
|
||
(та же формула, что в строках 595 и 679).
|
||
|
||
- [ ] **Step 6: Прогнать, собрать, посмотреть глазами**
|
||
|
||
Run: `cd web && npm run lint && npm test && npm run build`
|
||
Expected: PASS — 9 тестов страницы + прежние; сборка без ошибок.
|
||
|
||
Проверка вручную на тестовом хабе (его поднимает `web/scripts/test-hub.sh` из Task 15; если Task 15 ещё не сделан —
|
||
поднять так: `JIRA_HUB_HOME=$(mktemp -d) uv run jira-hub init --config tests/fixtures/config.example.json && JIRA_HUB_HOME=… uv run jira-hub serve --port 8791`
|
||
и загрузить в зеркало `hub-basic` через `POST /agent/state` агентским токеном): `cd web && npm run dev`,
|
||
открыть `http://127.0.0.1:5173/app/board`, войти клиентским токеном тестового хаба, проверить: дорожки, стрелки,
|
||
перетаскивание с направляющими, растяжка, двойной щелчок по уголку, свёртка и перестановка дорожек, «по датам» с
|
||
растяжкой колонки, «Проблемы», таблица, экспорт, тема. Найденные расхождения со старой доской
|
||
(`http://127.0.0.1:8791/links`) исправить в этой задаче.
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add web/src/board
|
||
git commit -m "feat(web): страница доски — панель, фильтры, легенда, проблемы, таблица, экспорт (#21)
|
||
|
||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
### Task 13: страница «Задачи» — список зеркала с фильтрами в адресе
|
||
|
||
**Модель:** haiku (код готов).
|
||
|
||
**Files:**
|
||
- Modify: `web/src/issues/IssuesPage.tsx` (заменяет временную страницу Task 4)
|
||
- Create: `web/src/issues/options.ts`, `web/src/issues/issues.css`
|
||
- Test: `web/src/issues/IssuesPage.test.tsx`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `useIssues`, `useBoard`, `useSettings`, `ISSUE_FILTER_NAMES`, `IssueFilters`, `ISSUE_PAGE` (Task 3).
|
||
- Produces: `IssuesPage` (`data-testid="page-issues"`); `options.ts`: `filterOptions(snap: BoardSnapshot | undefined,
|
||
s: HubSettings | undefined): Record<"sprint" | "assignee" | "work_kind" | "fact_status" | "status" | "label" | "epic",
|
||
{value: string; label: string}[]>`, `epicNames(snap): Record<string, string>`, `dueOf(i: {planned_end, duedate}): string`.
|
||
|
||
- [ ] **Step 1: Падающий тест** — `web/src/issues/IssuesPage.test.tsx`:
|
||
|
||
```tsx
|
||
import { screen, waitFor } from "@testing-library/react";
|
||
import userEvent from "@testing-library/user-event";
|
||
import { readFileSync } from "node:fs";
|
||
import { beforeEach, describe, expect, it } from "vitest";
|
||
import { setToken } from "../auth/token.ts";
|
||
import settings from "../board/__fixtures__/settings.json";
|
||
import { json, mockFetch } from "../test/fetchMock.ts";
|
||
import { renderWithProviders } from "../test/render.tsx";
|
||
import { IssuesPage } from "./IssuesPage.tsx";
|
||
|
||
const SNAP = JSON.parse(readFileSync(new URL("../board/__fixtures__/hub-basic.board.json", import.meta.url), "utf8"));
|
||
const row = (n: number) => ({ ...SNAP.issues[1], key: `AURORA-${9000 + n}`, summary: `Задача ${n}`, fetched_at: "" });
|
||
|
||
function hub(pages: (params: URLSearchParams) => unknown[]) {
|
||
return mockFetch((_m, url) => {
|
||
if (url.pathname === "/settings") return json(200, settings);
|
||
if (url.pathname === "/board") return json(200, SNAP);
|
||
if (url.pathname === "/issues") return json(200, { issues: pages(url.searchParams) });
|
||
return json(404, {});
|
||
});
|
||
}
|
||
const issuesCalls = (fetch: ReturnType<typeof hub>) =>
|
||
fetch.mock.calls.map(([u]) => new URL(String(u), "http://hub.test")).filter((u) => u.pathname === "/issues");
|
||
|
||
describe("список задач", () => {
|
||
beforeEach(() => setToken("tok"));
|
||
|
||
it("фильтры из адреса уходят в GET /issues; ключ ведёт на карточку", async () => {
|
||
const fetch = hub(() => [row(1)]);
|
||
renderWithProviders(<IssuesPage />, { route: "/issues?sprint=AWPCA_Спринт 2&neighbor=1", path: "/issues" });
|
||
expect(await screen.findByRole("link", { name: "AURORA-9001" })).toHaveAttribute("href", "/issues/AURORA-9001");
|
||
const q = issuesCalls(fetch)[0].searchParams;
|
||
expect(q.get("sprint")).toBe("AWPCA_Спринт 2");
|
||
expect(q.get("neighbor")).toBe("1");
|
||
expect(q.get("limit")).toBe("50");
|
||
expect(q.get("offset")).toBe("0");
|
||
});
|
||
|
||
it("выбор исполнителя пишет его в адрес и в запрос", async () => {
|
||
const fetch = hub(() => [row(1)]);
|
||
const { router } = renderWithProviders(<IssuesPage />, { route: "/issues", path: "/issues" });
|
||
await screen.findByRole("link", { name: "AURORA-9001" });
|
||
await userEvent.selectOptions(screen.getByLabelText("Исполнитель"), "Петров Пётр Петрович");
|
||
await waitFor(() => expect(router.state.location.search).toContain("assignee="));
|
||
await waitFor(() => expect(issuesCalls(fetch).at(-1)!.searchParams.get("assignee")).toBe("Петров Пётр Петрович"));
|
||
});
|
||
|
||
it("«Показать ещё» до неполной порции", async () => {
|
||
const fetch = hub((p) => (p.get("offset") === "0" ? Array.from({ length: 50 }, (_, i) => row(i + 1)) : Array.from({ length: 17 }, (_, i) => row(i + 51))));
|
||
renderWithProviders(<IssuesPage />, { route: "/issues", path: "/issues" });
|
||
await userEvent.click(await screen.findByRole("button", { name: "Показать ещё" }));
|
||
await waitFor(() => expect(screen.getAllByRole("row")).toHaveLength(1 + 67));
|
||
expect(screen.queryByRole("button", { name: "Показать ещё" })).toBeNull();
|
||
expect(issuesCalls(fetch).at(-1)!.searchParams.get("offset")).toBe("50");
|
||
});
|
||
|
||
it("поиск — один запрос после паузы ввода", async () => {
|
||
const fetch = hub(() => [row(1)]);
|
||
renderWithProviders(<IssuesPage />, { route: "/issues", path: "/issues" });
|
||
await screen.findByRole("link", { name: "AURORA-9001" });
|
||
const before = issuesCalls(fetch).length;
|
||
await userEvent.type(screen.getByLabelText("Поиск по ключу или теме"), "свод");
|
||
await waitFor(() => expect(issuesCalls(fetch).at(-1)!.searchParams.get("q")).toBe("свод"));
|
||
expect(issuesCalls(fetch).length).toBe(before + 1);
|
||
});
|
||
|
||
it("хаб не отвечает — сообщение и «Повторить»", async () => {
|
||
let down = true;
|
||
mockFetch((_m, url) => {
|
||
if (url.pathname === "/issues" && down) throw new TypeError("Failed to fetch");
|
||
if (url.pathname === "/settings") return json(200, settings);
|
||
if (url.pathname === "/board") return json(200, SNAP);
|
||
return json(200, { issues: [row(1)] });
|
||
});
|
||
renderWithProviders(<IssuesPage />, { route: "/issues", path: "/issues" });
|
||
expect(await screen.findByRole("alert")).toHaveTextContent("хаб не отвечает");
|
||
down = false;
|
||
await userEvent.click(screen.getByRole("button", { name: "Повторить" }));
|
||
expect(await screen.findByRole("link", { name: "AURORA-9001" })).toBeInTheDocument();
|
||
});
|
||
|
||
it("эпик показывается названием из снимка", async () => {
|
||
hub(() => [row(1)]);
|
||
renderWithProviders(<IssuesPage />, { route: "/issues", path: "/issues" });
|
||
expect(await screen.findByRole("cell", { name: "Тестовый эпик" })).toBeInTheDocument();
|
||
});
|
||
});
|
||
```
|
||
|
||
Run: `cd web && npx vitest run src/issues` → FAIL.
|
||
|
||
- [ ] **Step 2: Реализация**
|
||
|
||
`web/src/issues/options.ts`:
|
||
|
||
```ts
|
||
import type { BoardSnapshot, HubSettings } from "../api/types.ts";
|
||
|
||
type Opt = { value: string; label: string };
|
||
const uniq = (xs: (string | null | undefined)[]) => [...new Set(xs.filter((x): x is string => !!x))].sort((a, b) => a.localeCompare(b, "ru"));
|
||
const isEpic = (t: string | null) => /^(Фича|Epic|Эпик)$/i.test(t ?? "");
|
||
|
||
export const epicNames = (snap: BoardSnapshot | undefined): Record<string, string> =>
|
||
Object.fromEntries((snap?.issues ?? []).filter((i) => isEpic(i.issue_type)).map((i) => [i.key, i.summary || i.key]));
|
||
|
||
export const dueOf = (i: { planned_end: string | null; duedate: string | null }) => (i.planned_end || i.duedate || "").slice(0, 10);
|
||
|
||
// варианты выпадающих списков фильтров: из снимка /board и словарей настроек, без новых маршрутов API
|
||
export function filterOptions(snap: BoardSnapshot | undefined, s: HubSettings | undefined) {
|
||
const issues = snap?.issues ?? [];
|
||
const names = epicNames(snap);
|
||
const opt = (xs: string[]): Opt[] => xs.map((v) => ({ value: v, label: v }));
|
||
return {
|
||
sprint: opt(uniq(issues.map((i) => i.sprint))),
|
||
assignee: opt(uniq(issues.map((i) => i.assignee))),
|
||
work_kind: opt(uniq([...Object.keys(s?.kind_labels ?? {}), ...issues.map((i) => i.work_kind)])),
|
||
fact_status: opt(uniq([...Object.keys(s?.status_labels ?? {}), ...issues.map((i) => i.fact_status)])),
|
||
status: opt(uniq(issues.map((i) => i.status))),
|
||
label: opt(uniq(issues.flatMap((i) => i.labels))),
|
||
epic: uniq(issues.map((i) => i.epic_key)).map((k) => ({ value: k, label: names[k] ?? k })),
|
||
};
|
||
}
|
||
```
|
||
|
||
`web/src/issues/IssuesPage.tsx`:
|
||
|
||
```tsx
|
||
import { useEffect, useMemo, useState } from "react";
|
||
import { Link, useSearchParams } from "react-router";
|
||
import { ISSUE_FILTER_NAMES, useBoard, useIssues, useSettings, type IssueFilters } from "../api/queries.ts";
|
||
import { dueOf, epicNames, filterOptions } from "./options.ts";
|
||
import "./issues.css";
|
||
|
||
const SELECTS = [
|
||
["sprint", "Спринт"], ["assignee", "Исполнитель"], ["work_kind", "Вид работы"], ["fact_status", "Статус по факту"],
|
||
["status", "Статус Jira"], ["label", "Метка"], ["epic", "Эпик"],
|
||
] as const;
|
||
const Q_DELAY = 300; // поиск — после паузы ввода (R-7)
|
||
|
||
export function IssuesPage() {
|
||
const [params, setParams] = useSearchParams();
|
||
const filters = useMemo<IssueFilters>(() =>
|
||
Object.fromEntries(ISSUE_FILTER_NAMES.map((n) => [n, params.get(n) ?? ""]).filter(([, v]) => v)), [params]);
|
||
const set = (name: string, value: string) => setParams((p) => { const n = new URLSearchParams(p); if (value) n.set(name, value); else n.delete(name); return n; });
|
||
const [q, setQ] = useState(filters.q ?? "");
|
||
useEffect(() => {
|
||
if (q === (filters.q ?? "")) return;
|
||
const t = setTimeout(() => setParams((p) => { const n = new URLSearchParams(p); if (q) n.set("q", q); else n.delete("q"); return n; }), Q_DELAY);
|
||
return () => clearTimeout(t);
|
||
}, [q, filters.q, setParams]);
|
||
|
||
const board = useBoard(), settings = useSettings();
|
||
const opts = useMemo(() => filterOptions(board.data, settings.data), [board.data, settings.data]);
|
||
const names = useMemo(() => epicNames(board.data), [board.data]);
|
||
const list = useIssues(filters);
|
||
const rows = list.data?.pages.flat() ?? [];
|
||
|
||
return (
|
||
<div data-testid="page-issues" className="issues">
|
||
<div className="filters">
|
||
{SELECTS.map(([name, label]) => (
|
||
<label key={name}>{label}
|
||
<select aria-label={label} value={filters[name] ?? ""} onChange={(e) => set(name, e.target.value)}>
|
||
<option value="">все</option>
|
||
{opts[name].map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||
</select>
|
||
</label>
|
||
))}
|
||
<label className="check"><input type="checkbox" checked={filters.neighbor === "1"} onChange={(e) => set("neighbor", e.target.checked ? "1" : "")} />только соседи</label>
|
||
<input type="search" aria-label="Поиск по ключу или теме" placeholder="ключ или тема" value={q} onChange={(e) => setQ(e.target.value)} />
|
||
</div>
|
||
{list.isError && (
|
||
<p className="warn" role="alert">{(list.error as Error).message}{" "}
|
||
<button className="btn sm" type="button" onClick={() => void list.refetch()}>Повторить</button></p>
|
||
)}
|
||
<table className="grid">
|
||
<thead><tr><th>Ключ</th><th>Тема</th><th>Тип</th><th>Статус Jira</th><th>Исполнитель</th><th>Спринт</th><th>Срок</th><th>Эпик</th><th>Вид работы</th><th>Статус по факту</th><th>Сосед</th></tr></thead>
|
||
<tbody>
|
||
{rows.map((i) => (
|
||
<tr key={i.key}>
|
||
<td><Link to={`/issues/${i.key}`}>{i.key}</Link></td><td>{i.summary}</td><td>{i.issue_type}</td><td>{i.status}</td>
|
||
<td>{i.assignee}</td><td>{i.sprint}</td><td>{dueOf(i)}</td><td>{i.epic_key ? names[i.epic_key] ?? i.epic_key : ""}</td>
|
||
<td>{i.work_kind}</td><td>{i.fact_status}</td><td>{i.neighbor ? "да" : ""}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
{list.isPending && <p className="hint">загрузка…</p>}
|
||
{!list.isPending && !rows.length && !list.isError && <p className="hint">По этим фильтрам задач нет.</p>}
|
||
{list.hasNextPage && <button className="btn" type="button" disabled={list.isFetchingNextPage} onClick={() => void list.fetchNextPage()}>Показать ещё</button>}
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
`web/src/issues/issues.css`:
|
||
|
||
```css
|
||
.issues .filters{display:flex;flex-wrap:wrap;gap:8px 12px;align-items:end;margin:12px 0}
|
||
.issues .filters label{display:flex;flex-direction:column;font-size:13px;color:var(--muted);gap:2px}
|
||
.issues .filters label.check{flex-direction:row;align-items:center;gap:6px;color:var(--fg)}
|
||
.issues select,.issues input[type=search]{font:inherit;font-size:14px;height:32px;border:1px solid var(--line);border-radius:6px;background:var(--card);color:var(--fg);padding:0 8px}
|
||
.grid{width:100%;border-collapse:collapse;font-size:14px}
|
||
.grid th,.grid td{border-bottom:1px solid var(--line);padding:6px 8px;text-align:left;vertical-align:top}
|
||
.grid th{color:var(--muted);font-weight:500;position:sticky;top:0;background:var(--bg)}
|
||
.grid a{color:var(--accent)}
|
||
```
|
||
|
||
- [ ] **Step 3: Прогнать**
|
||
|
||
Run: `cd web && npm run lint && npx vitest run src/issues`
|
||
Expected: PASS — 6 тестов.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add web/src/issues
|
||
git commit -m "feat(web): список задач зеркала — фильтры в адресе, порции по 50, поиск (#21)
|
||
|
||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 14: карточка задачи и служебные заметки
|
||
|
||
**Модель:** haiku (код готов).
|
||
|
||
**Files:**
|
||
- Modify: `web/src/issues/IssuePage.tsx` (заменяет временную страницу Task 4)
|
||
- Create: `web/src/issues/NoteForm.tsx`
|
||
- Modify: `web/src/issues/issues.css`
|
||
- Test: `web/src/issues/IssuePage.test.tsx`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `useIssue`, `useSaveNote`, `useSettings`, `qk` (Task 3); `ApiError` (Task 3); `JIRA_URL` (Task 6); `dueOf`
|
||
(Task 13).
|
||
- Produces: `IssuePage` (`data-testid="page-issue"`), `NoteForm({issue})`; `changedFields(initial, values): NoteFields`.
|
||
|
||
- [ ] **Step 1: Падающий тест** — `web/src/issues/IssuePage.test.tsx`:
|
||
|
||
```tsx
|
||
import { act, render, screen, waitFor } from "@testing-library/react";
|
||
import userEvent from "@testing-library/user-event";
|
||
import { QueryClientProvider } from "@tanstack/react-query";
|
||
import { createMemoryRouter, RouterProvider } from "react-router";
|
||
import { beforeEach, describe, expect, it } from "vitest";
|
||
import { makeQueryClient } from "../api/queryClient.ts";
|
||
import { setToken } from "../auth/token.ts";
|
||
import settings from "../board/__fixtures__/settings.json";
|
||
import { json, mockFetch } from "../test/fetchMock.ts";
|
||
import { IssuePage } from "./IssuePage.tsx";
|
||
|
||
const note = (over = {}) => ({ 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 });
|
||
const issue = (key: string, over = {}) => ({
|
||
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", labels: ["kind-dev"], sprint: "AWPCA_Спринт 2", created: null,
|
||
jira_updated: null, fetched_at: "", neighbor: false,
|
||
links_out: [{ target_key: "AURORA-9002", link_type: "ASUPR_Блокер_Начала" }],
|
||
links_in: [{ source_key: "AURORA-9003", link_type: "Duplicate" }],
|
||
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 }],
|
||
...over,
|
||
});
|
||
const deferred = <T,>() => { let resolve!: (v: T) => void; const promise = new Promise<T>((r) => { resolve = r; }); return { promise, resolve }; };
|
||
|
||
function open(path: string) {
|
||
const router = createMemoryRouter([
|
||
{ path: "/issues/:key", element: <IssuePage /> },
|
||
{ path: "/board", element: <p>доска</p> },
|
||
], { initialEntries: [path] });
|
||
render(<QueryClientProvider client={makeQueryClient()}><RouterProvider router={router} /></QueryClientProvider>);
|
||
return router;
|
||
}
|
||
|
||
describe("карточка задачи", () => {
|
||
beforeEach(() => setToken("tok"));
|
||
|
||
it("поля, описание с переносами, связи с подписями, команды", async () => {
|
||
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001")));
|
||
open("/issues/AURORA-9001");
|
||
expect(await screen.findByRole("heading", { name: /AURORA-9001/ })).toBeInTheDocument();
|
||
expect(screen.getByText("строка 1\nстрока 2", { normalizer: (s) => s })).toHaveClass("description");
|
||
expect(await screen.findByText("блокирует начало")).toBeInTheDocument();
|
||
expect(screen.getByRole("link", { name: "AURORA-9002" })).toHaveAttribute("href", "/issues/AURORA-9002");
|
||
expect(screen.getByText("Duplicate")).toBeInTheDocument(); // неизвестный тип — имя Jira
|
||
expect(screen.getByRole("cell", { name: "ensure_fields" })).toBeInTheDocument();
|
||
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(404, { error: "not_found", detail: "issue" }));
|
||
open("/issues/AURORA-1");
|
||
expect(await screen.findByText(/Задачи AURORA-1 нет в зеркале/)).toBeInTheDocument();
|
||
expect(screen.getByRole("link", { name: "к списку задач" })).toHaveAttribute("href", "/issues");
|
||
});
|
||
|
||
it("сохраняются только изменённые поля, очищенное — null", async () => {
|
||
let put: unknown = null;
|
||
mockFetch((m, url, body) => {
|
||
if (url.pathname === "/settings") return json(200, settings);
|
||
if (m === "PUT") { put = body; return json(200, note({ rest_text: "тесты", done_text: null, updated_by: "web" })); }
|
||
return json(200, issue("AURORA-9001"));
|
||
});
|
||
open("/issues/AURORA-9001");
|
||
const rest = await screen.findByLabelText("Осталось");
|
||
await userEvent.clear(rest); await userEvent.type(rest, "тесты");
|
||
await userEvent.clear(screen.getByLabelText("Сделано"));
|
||
await userEvent.click(screen.getByRole("button", { name: "Сохранить" }));
|
||
await waitFor(() => expect(put).toEqual({ rest_text: "тесты", done_text: null, updated_by: "web" }));
|
||
expect(await screen.findByText(/сохранено .*web/)).toBeInTheDocument();
|
||
expect(screen.getByRole("button", { name: "Сохранить" })).toBeDisabled();
|
||
});
|
||
|
||
it("422 — текст ошибки у формы, введённое на месте", async () => {
|
||
mockFetch((m, url) => {
|
||
if (url.pathname === "/settings") return json(200, settings);
|
||
if (m === "PUT") return json(422, { error: "bad_note", detail: "неизвестные поля заметки: x" });
|
||
return json(200, issue("AURORA-9001"));
|
||
});
|
||
open("/issues/AURORA-9001");
|
||
const owner = await screen.findByLabelText("Заметка владельца");
|
||
await userEvent.type(owner, "проверить");
|
||
await userEvent.click(screen.getByRole("button", { name: "Сохранить" }));
|
||
expect(await screen.findByRole("alert")).toHaveTextContent("неизвестные поля заметки: x");
|
||
expect(owner).toHaveValue("проверить");
|
||
});
|
||
|
||
it("уход с несохранёнными правками требует подтверждения", async () => {
|
||
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001")));
|
||
const router = open("/issues/AURORA-9001");
|
||
await userEvent.type(await screen.findByLabelText("Заметка владельца"), "черновик");
|
||
await act(async () => { await router.navigate("/board"); });
|
||
expect(await screen.findByText("Есть несохранённые правки заметки. Уйти без сохранения?")).toBeInTheDocument();
|
||
await userEvent.click(screen.getByRole("button", { name: "Остаться" }));
|
||
expect(router.state.location.pathname).toBe("/issues/AURORA-9001");
|
||
expect(screen.getByLabelText("Заметка владельца")).toHaveValue("черновик");
|
||
});
|
||
|
||
it("поздний ответ прежней карточки не подменяет новую", async () => {
|
||
const slow = deferred<Response>();
|
||
mockFetch((_m, url) => {
|
||
if (url.pathname === "/settings") return json(200, settings);
|
||
if (url.pathname === "/issues/AURORA-9001") return slow.promise;
|
||
return json(200, issue("AURORA-9002"));
|
||
});
|
||
const router = open("/issues/AURORA-9001");
|
||
await act(async () => { await router.navigate("/issues/AURORA-9002"); });
|
||
expect(await screen.findByRole("heading", { name: /AURORA-9002/ })).toBeInTheDocument();
|
||
slow.resolve(json(200, issue("AURORA-9001")));
|
||
await new Promise((r) => setTimeout(r, 20));
|
||
expect(screen.getByRole("heading", { name: /AURORA-9002/ })).toBeInTheDocument();
|
||
expect(screen.queryByRole("heading", { name: /AURORA-9001/ })).toBeNull();
|
||
});
|
||
});
|
||
```
|
||
|
||
Run: `cd web && npx vitest run src/issues/IssuePage.test.tsx` → FAIL.
|
||
|
||
- [ ] **Step 2: `NoteForm.tsx`**
|
||
|
||
```tsx
|
||
import { useEffect, useMemo, useState } from "react";
|
||
import { useBlocker } from "react-router";
|
||
import { ApiError } from "../api/client.ts";
|
||
import { useSaveNote, useSettings } from "../api/queries.ts";
|
||
import { NOTE_FIELDS, type IssueDetail, type NoteField, type NoteFields } from "../api/types.ts";
|
||
import { fmtStamp } from "../board/model/util.ts";
|
||
|
||
type Values = Record<NoteField, string>;
|
||
const valuesOf = (n: IssueDetail["note"]): Values =>
|
||
Object.fromEntries(NOTE_FIELDS.map((f) => [f, n?.[f] ?? ""])) as Values;
|
||
|
||
// только изменённые поля; очищенное поле — null (как у MCP notes_set)
|
||
export function changedFields(initial: Values, values: Values): NoteFields {
|
||
const out: NoteFields = {};
|
||
for (const f of NOTE_FIELDS) if (values[f] !== initial[f]) out[f] = values[f] === "" ? null : values[f];
|
||
return out;
|
||
}
|
||
|
||
const TEXTS: [NoteField, string][] = [["done_text", "Сделано"], ["rest_text", "Осталось"], ["owner_note", "Заметка владельца"]];
|
||
|
||
export function NoteForm({ issue }: { issue: IssueDetail }) {
|
||
const settings = useSettings();
|
||
const save = useSaveNote(issue.key);
|
||
const [initial, setInitial] = useState(() => valuesOf(issue.note));
|
||
const [values, setValues] = useState(initial);
|
||
const changed = useMemo(() => changedFields(initial, values), [initial, values]);
|
||
const dirty = Object.keys(changed).length > 0;
|
||
|
||
const blocker = useBlocker(({ currentLocation, nextLocation }) => dirty && currentLocation.pathname !== nextLocation.pathname);
|
||
useEffect(() => {
|
||
if (!dirty) return;
|
||
const warn = (e: BeforeUnloadEvent) => { e.preventDefault(); };
|
||
window.addEventListener("beforeunload", warn);
|
||
return () => window.removeEventListener("beforeunload", warn);
|
||
}, [dirty]);
|
||
|
||
const kinds = Object.keys(settings.data?.kind_labels ?? {});
|
||
const kindOptions = values.work_kind && !kinds.includes(values.work_kind) ? [values.work_kind, ...kinds] : kinds;
|
||
const facts = Object.keys(settings.data?.status_labels ?? {});
|
||
const set = (f: NoteField) => (e: { target: { value: string } }) => setValues((v) => ({ ...v, [f]: e.target.value }));
|
||
|
||
const submit = () => save.mutate(changed, {
|
||
onSuccess: (note) => { const v = valuesOf(note); setInitial(v); setValues(v); },
|
||
});
|
||
const err = save.error instanceof ApiError ? save.error.detail ?? save.error.message : save.error?.message;
|
||
const saved = save.data ?? issue.note;
|
||
|
||
return (
|
||
<form className="note" onSubmit={(e) => { e.preventDefault(); submit(); }}>
|
||
<h3>Служебные заметки</h3>
|
||
<label>Вид работы
|
||
<select value={values.work_kind} onChange={set("work_kind")}>
|
||
<option value="">—</option>
|
||
{kindOptions.map((k) => <option key={k} value={k}>{k}</option>)}
|
||
</select>
|
||
</label>
|
||
<label>Статус по факту
|
||
<input list="fact-statuses" value={values.fact_status} onChange={set("fact_status")} />
|
||
<datalist id="fact-statuses">{facts.map((f) => <option key={f} value={f} />)}</datalist>
|
||
</label>
|
||
{TEXTS.map(([f, label]) => (
|
||
<label key={f}>{label}<textarea rows={3} value={values[f]} onChange={set(f)} /></label>
|
||
))}
|
||
{err && <p className="err" role="alert">{err}</p>}
|
||
<div className="row">
|
||
<button className="btn" type="submit" disabled={!dirty || save.isPending}>Сохранить</button>
|
||
{saved && <span className="hint">сохранено {fmtStamp(saved.updated_at)}, {saved.updated_by}</span>}
|
||
</div>
|
||
{blocker.state === "blocked" && (
|
||
<div className="confirm" role="dialog">
|
||
<span>Есть несохранённые правки заметки. Уйти без сохранения?</span>
|
||
<button className="btn" type="button" onClick={() => blocker.proceed()}>Уйти</button>
|
||
<button className="btn" type="button" onClick={() => blocker.reset()}>Остаться</button>
|
||
</div>
|
||
)}
|
||
</form>
|
||
);
|
||
}
|
||
```
|
||
|
||
Метки `<label>` с вложенным полем дают доступные имена «Вид работы», «Статус по факту», «Сделано», «Осталось»,
|
||
«Заметка владельца» — по ним ищут тесты.
|
||
|
||
- [ ] **Step 3: `IssuePage.tsx`**
|
||
|
||
```tsx
|
||
import { Link, useParams } from "react-router";
|
||
import { ApiError } from "../api/client.ts";
|
||
import { useIssue, useSettings } from "../api/queries.ts";
|
||
import { JIRA_URL } from "../board/constants.ts";
|
||
import { fmtStamp } from "../board/model/util.ts";
|
||
import { NoteForm } from "./NoteForm.tsx";
|
||
import { dueOf } from "./options.ts";
|
||
import "./issues.css";
|
||
|
||
export function IssuePage() {
|
||
const { key = "" } = useParams();
|
||
const issue = useIssue(key);
|
||
const settings = useSettings();
|
||
// подпись типа связи: логический тип из link_types настроек, иначе имя типа Jira
|
||
const typeName = (jira: string) =>
|
||
Object.entries(settings.data?.link_types ?? {}).find(([, j]) => j === jira)?.[0] ?? jira;
|
||
|
||
if (issue.isPending) return <p className="hint" data-testid="page-issue">загрузка {key}…</p>;
|
||
if (issue.error instanceof ApiError && issue.error.status === 404) {
|
||
return <p data-testid="page-issue">Задачи {key} нет в зеркале хаба. <Link to="/issues">к списку задач</Link></p>;
|
||
}
|
||
if (issue.isError) {
|
||
return (
|
||
<p className="warn" role="alert" data-testid="page-issue">{issue.error.message}{" "}
|
||
<button className="btn sm" type="button" onClick={() => void issue.refetch()}>Повторить</button></p>
|
||
);
|
||
}
|
||
const i = issue.data;
|
||
const fields: [string, string | null][] = [
|
||
["Тип", i.issue_type], ["Статус Jira", i.status], ["Приоритет", i.priority], ["Исполнитель", i.assignee],
|
||
["Автор", i.reporter], ["Спринт", i.sprint], ["Срок", dueOf(i) || null], ["Плановое начало", i.planned_start],
|
||
["Эпик", i.epic_key], ["Метки", i.labels.join(", ") || null], ["Сосед по связям", i.neighbor ? "да" : null],
|
||
["Обновлена в Jira", i.jira_updated ? fmtStamp(i.jira_updated) : null],
|
||
];
|
||
return (
|
||
<div data-testid="page-issue" className="issue">
|
||
<p><Link to="/issues">← задачи</Link></p>
|
||
<h2>{i.key} · {i.summary}</h2>
|
||
<p><a href={`${JIRA_URL}/browse/${i.key}`} target="_blank" rel="noopener">Открыть в Jira</a></p>
|
||
<div className="cols">
|
||
<div>
|
||
<dl className="fields">{fields.filter(([, v]) => v).map(([k, v]) => <div key={k}><dt>{k}</dt><dd>{v}</dd></div>)}</dl>
|
||
{i.description && <pre className="description">{i.description}</pre>}
|
||
<h3>Связи</h3>
|
||
<ul className="links">
|
||
{i.links_out.map((l) => <li key={"o" + l.target_key + l.link_type}><span>{typeName(l.link_type)}</span> → <Link to={`/issues/${l.target_key}`}>{l.target_key}</Link></li>)}
|
||
{i.links_in.map((l) => <li key={"i" + l.source_key + l.link_type}><Link to={`/issues/${l.source_key}`}>{l.source_key}</Link> → <span>{typeName(l.link_type)}</span> → эта задача</li>)}
|
||
{!i.links_out.length && !i.links_in.length && <li className="hint">связей нет</li>}
|
||
</ul>
|
||
<h3>Последние команды</h3>
|
||
<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>
|
||
</div>
|
||
<NoteForm key={i.key} issue={i} />
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
`issues.css` — дописать:
|
||
|
||
```css
|
||
.issue .cols{display:grid;grid-template-columns:minmax(0,1fr) 360px;gap:24px}
|
||
@media (max-width:900px){.issue .cols{grid-template-columns:1fr}}
|
||
.issue .fields{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:4px 16px;margin:0}
|
||
.issue .fields dt{font-size:12px;color:var(--muted)}
|
||
.issue .fields dd{margin:0}
|
||
.issue .description{white-space:pre-wrap;font:inherit;background:var(--panel);border:1px solid var(--line);border-radius:6px;padding:10px}
|
||
.issue .links{padding-left:18px}
|
||
.note{display:flex;flex-direction:column;gap:10px;background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:12px;align-self:start}
|
||
.note label{display:flex;flex-direction:column;gap:4px;font-size:13px;color:var(--muted)}
|
||
.note input,.note select,.note textarea{font:inherit;font-size:14px;color:var(--fg);background:var(--card);border:1px solid var(--line);border-radius:6px;padding:6px 8px}
|
||
.note .err{color:var(--p-crit)}
|
||
.note .confirm{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
|
||
```
|
||
|
||
- [ ] **Step 4: Прогнать**
|
||
|
||
Run: `cd web && npm run lint && npm test`
|
||
Expected: PASS — 6 тестов карточки + прежние. Если тест «описание с переносами» не находит текст из-за нормализации
|
||
пробелов Testing Library — оставить `normalizer: (s) => s` (он отключает схлопывание переводов строк).
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add web/src/issues
|
||
git commit -m "feat(web): карточка задачи — поля, связи, команды, правка служебных заметок (#21)
|
||
|
||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
### Task 15: тестовый хаб и e2e Playwright, сверка доски со старой страницей
|
||
|
||
**Модель:** sonnet.
|
||
|
||
**Files:**
|
||
- Create: `web/scripts/test-hub.sh`, `web/scripts/seed-hub.py`, `web/playwright.config.ts`, `web/e2e/hub.ts`,
|
||
`web/e2e/login.spec.ts`, `web/e2e/board.spec.ts`, `web/e2e/issues.spec.ts`, `web/e2e/compare.spec.ts`
|
||
- Modify: `.gitignore` (+ `web/.e2e-hub/`), `web/eslint.config.js` (игнор `.e2e-hub`)
|
||
|
||
**Interfaces:**
|
||
- Consumes: сборка `make web` (Task 2), раздача `/app` (Task 1), страница `links.html` по `/links` (до Task 16), фикстура
|
||
`hub-basic.board.json` (Task 5).
|
||
- Produces: `bash web/scripts/test-hub.sh` — хаб на `127.0.0.1:8791` с данными `hub-basic` и токенами в
|
||
`$JIRA_HUB_HOME/tokens.json` (`{"agent": …, "client": …}`); `npm run e2e`.
|
||
|
||
- [ ] **Step 1: Тестовый хаб**
|
||
|
||
`web/scripts/seed-hub.py` (запускается из корня репозитория через `uv run python`):
|
||
|
||
```python
|
||
"""Заполнить зеркало тестового хаба фикстурой доски и записать токены рядом с базой (для e2e и npm run dev).
|
||
Пишет в базу напрямую (mirror.apply_state, set_note), до запуска serve: гонки с первым запросом нет."""
|
||
import json
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
from jira_hub import db, mirror, paths, settings
|
||
|
||
snap = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
|
||
conn = db.connect(paths.db_path())
|
||
settings.ensure_defaults(conn)
|
||
issues = [{k: v for k, v in i.items() if k not in ("fact_status", "work_kind", "neighbor")} | {"id": i["key"]} for i in snap["issues"]]
|
||
links = [{"source_key": l["source"], "target_key": l["target"], "link_type": l["link_type"]} for l in snap["links"]]
|
||
mirror.apply_state(conn, issues, links, neighbor_keys=[i["key"] for i in snap["issues"] if i["neighbor"]])
|
||
for i in snap["issues"]:
|
||
note = {k: i[k] for k in ("fact_status", "work_kind") if i.get(k)}
|
||
if note:
|
||
mirror.set_note(conn, i["key"], note, "seed")
|
||
(paths.home() / "tokens.json").write_text(json.dumps(settings.tokens(conn)), encoding="utf-8")
|
||
conn.close()
|
||
print("зеркало:", len(issues), "задач,", len(links), "связей; токены —", paths.home() / "tokens.json")
|
||
```
|
||
|
||
`web/scripts/test-hub.sh`:
|
||
|
||
```bash
|
||
#!/usr/bin/env bash
|
||
# Тестовый хаб для разработки фронта и e2e: свой JIRA_HUB_HOME (по умолчанию web/.e2e-hub, очищается), порт 8791,
|
||
# зеркало — фикстура hub-basic. Живой хаб (:8790, ~/.local/share/jira-hub) не трогается.
|
||
set -euo pipefail
|
||
WEB=$(cd "$(dirname "$0")/.." && pwd)
|
||
ROOT=$(cd "$WEB/.." && pwd)
|
||
export JIRA_HUB_HOME="${JIRA_HUB_HOME:-$WEB/.e2e-hub}"
|
||
PORT="${PORT:-8791}"
|
||
case "$JIRA_HUB_HOME" in "$HOME/.local/share/jira-hub"*) echo "отказ: это каталог живого хаба" >&2; exit 2;; esac
|
||
rm -rf "$JIRA_HUB_HOME"
|
||
cd "$ROOT"
|
||
uv run jira-hub init --config tests/fixtures/config.example.json --force >/dev/null
|
||
uv run python web/scripts/seed-hub.py web/src/board/__fixtures__/hub-basic.board.json
|
||
exec uv run jira-hub serve --host 127.0.0.1 --port "$PORT"
|
||
```
|
||
|
||
Проверка: `bash web/scripts/test-hub.sh` в отдельном терминале → `curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8791/app/`
|
||
даёт 200 (если сборка есть) и `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/board | head -c 200`
|
||
— снимок с AURORA-9001. Остановить Ctrl-C.
|
||
|
||
- [ ] **Step 2: Конфигурация Playwright** — `web/playwright.config.ts`:
|
||
|
||
```ts
|
||
import { defineConfig } from "@playwright/test";
|
||
import { resolve } from "node:path";
|
||
|
||
export const HUB_HOME = resolve(import.meta.dirname, ".e2e-hub");
|
||
|
||
export default defineConfig({
|
||
testDir: "e2e",
|
||
workers: 1,
|
||
timeout: 60_000,
|
||
use: { baseURL: "http://127.0.0.1:8791", trace: "retain-on-failure", viewport: { width: 1600, height: 1000 } },
|
||
webServer: {
|
||
command: "npm run build && bash scripts/test-hub.sh",
|
||
url: "http://127.0.0.1:8791/app/",
|
||
reuseExistingServer: false,
|
||
timeout: 120_000,
|
||
env: { JIRA_HUB_HOME: HUB_HOME, PORT: "8791" },
|
||
},
|
||
});
|
||
```
|
||
|
||
Браузер для Playwright: `cd web && npx playwright install chromium` (один раз).
|
||
|
||
`web/e2e/hub.ts` — общие помощники:
|
||
|
||
```ts
|
||
import { expect, type Page } from "@playwright/test";
|
||
import { readFileSync } from "node:fs";
|
||
import { HUB_HOME } from "../playwright.config.ts";
|
||
|
||
export const tokens = () => JSON.parse(readFileSync(`${HUB_HOME}/tokens.json`, "utf8")) as { agent: string; client: string };
|
||
const BASE = "http://127.0.0.1:8791";
|
||
|
||
export async function login(page: Page, path = "/app/board") {
|
||
await page.goto(path);
|
||
await page.getByLabel("Клиентский токен хаба").fill(tokens().client);
|
||
await page.getByRole("button", { name: "Войти" }).click();
|
||
await expect(page.getByRole("link", { name: "Доска" })).toBeVisible();
|
||
}
|
||
|
||
async function call(path: string, body: unknown) {
|
||
const r = await fetch(BASE + path, { method: "POST", headers: { Authorization: "Bearer " + tokens().agent, "Content-Type": "application/json" }, body: JSON.stringify(body) });
|
||
expect(r.ok, `${path} → ${r.status}`).toBe(true);
|
||
return r.json();
|
||
}
|
||
|
||
// учебный агент: взять команду refresh, загрузить состояние зеркала как есть и отчитаться done
|
||
export async function agentServeRefresh() {
|
||
const { command } = await call("/agent/claim", { agent_version: 99, settings_version: null, last_jira_call_at: null });
|
||
expect(command?.type).toBe("refresh");
|
||
await call("/agent/state", { command_id: command.id, issues: [], links: [], neighbor_keys: [] });
|
||
await call(`/agent/commands/${command.id}/result`, { status: "done", result: { issues: 0 }, jira_calls: 1, rate_limited: 0 });
|
||
return command.id as number;
|
||
}
|
||
```
|
||
|
||
`POST /agent/state` с пустыми `issues` зеркало не меняет: `mirror.apply_state` обновляет только присланные задачи и
|
||
связи их источников (`src/jira_hub/mirror.py`, строки 15–43), а `last_sync` появляется по отчёту `done` команды refresh.
|
||
|
||
- [ ] **Step 3: Сценарии**
|
||
|
||
`web/e2e/login.spec.ts`:
|
||
|
||
```ts
|
||
import { expect, test } from "@playwright/test";
|
||
import { login } from "./hub.ts";
|
||
|
||
test("неверный токен — пояснение; верный — доска", async ({ page }) => {
|
||
await page.goto("/app/board");
|
||
await page.getByLabel("Клиентский токен хаба").fill("wrong-token");
|
||
await page.getByRole("button", { name: "Войти" }).click();
|
||
await expect(page.getByText(/Токен не подошёл/)).toBeVisible();
|
||
await login(page);
|
||
await expect(page.locator(".card[data-key]").first()).toBeVisible();
|
||
});
|
||
|
||
test("/ и /app ведут на доску", async ({ page }) => {
|
||
await login(page);
|
||
await page.goto("/app");
|
||
await expect(page).toHaveURL(/\/app\/board$/);
|
||
});
|
||
```
|
||
|
||
`web/e2e/board.spec.ts`:
|
||
|
||
```ts
|
||
import { expect, test } from "@playwright/test";
|
||
import { agentServeRefresh, login } from "./hub.ts";
|
||
|
||
test.beforeEach(async ({ page }) => { await login(page); });
|
||
|
||
test("перетаскивание за рамку двигает карточку и переживает перезагрузку", async ({ page }) => {
|
||
const card = page.locator('.board .card[data-key="AURORA-9001"]');
|
||
const before = await card.boundingBox();
|
||
const grip = card.locator(".grip.t");
|
||
const g = (await grip.boundingBox())!;
|
||
await page.mouse.move(g.x + g.width / 2, g.y + g.height / 2);
|
||
await page.mouse.down();
|
||
await page.mouse.move(g.x + g.width / 2 + 160, g.y + g.height / 2 + 40, { steps: 8 });
|
||
await page.mouse.up();
|
||
const after = (await card.boundingBox())!;
|
||
expect(Math.round(after.x - before!.x)).toBeGreaterThan(100);
|
||
await page.reload();
|
||
const reloaded = (await page.locator('.board .card[data-key="AURORA-9001"]').boundingBox())!;
|
||
expect(Math.abs(reloaded.x - after.x)).toBeLessThan(2);
|
||
expect(Math.abs(reloaded.y - after.y)).toBeLessThan(2);
|
||
});
|
||
|
||
test("растяжка за уголок и возврат размера двойным щелчком", async ({ page }) => {
|
||
const card = page.locator('.board .card[data-key="AURORA-9002"]');
|
||
const rsz = (await card.locator(".rsz").boundingBox())!;
|
||
const w0 = (await card.boundingBox())!.width;
|
||
await page.mouse.move(rsz.x + 2, rsz.y + 2);
|
||
await page.mouse.down();
|
||
await page.mouse.move(rsz.x + 82, rsz.y + 42, { steps: 6 });
|
||
await page.mouse.up();
|
||
expect((await card.boundingBox())!.width).toBeGreaterThan(w0 + 60);
|
||
await card.locator(".rsz").dblclick();
|
||
expect(Math.round((await card.boundingBox())!.width)).toBe(240);
|
||
});
|
||
|
||
test("«по датам» — колонки дат, подписи и стрелки", async ({ page }) => {
|
||
await page.getByRole("button", { name: "▥ по датам" }).click();
|
||
await expect(page.locator(".dcol").first()).toBeVisible();
|
||
await expect(page.locator(".dates-track .dlabel").first()).toBeVisible();
|
||
await expect(page.locator("svg.edges path")).not.toHaveCount(0);
|
||
});
|
||
|
||
test("«Обновить из Jira» с учебным агентом доходит до конца", async ({ page }) => {
|
||
const btn = page.getByRole("button", { name: "Обновить из Jira" });
|
||
await btn.click();
|
||
await expect(btn).toBeDisabled();
|
||
await expect(page.getByRole("status")).toContainText(/пакет #\d+/);
|
||
await agentServeRefresh();
|
||
await expect(page.getByRole("status")).toContainText(/пакет #\d+ выполнен/, { timeout: 15_000 });
|
||
await expect(btn).toBeEnabled();
|
||
await expect(page.getByText(/^Хаб · данные на /)).toBeVisible();
|
||
await expect(page.locator(".board .card[data-key]")).not.toHaveCount(0);
|
||
});
|
||
|
||
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("heading", { name: /AURORA-9001/ })).toBeVisible();
|
||
});
|
||
```
|
||
|
||
`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" }).click();
|
||
const rest = page.getByLabel("Осталось");
|
||
await rest.fill("e2e: дописать тесты");
|
||
await page.getByRole("button", { name: "Сохранить" }).click();
|
||
await expect(page.getByText(/сохранено .*web/)).toBeVisible();
|
||
await page.reload();
|
||
await expect(page.getByLabel("Осталось")).toHaveValue("e2e: дописать тесты");
|
||
});
|
||
```
|
||
|
||
`web/e2e/compare.spec.ts` — новая доска против старой страницы (`/links`, до Task 16) на тех же данных и том же
|
||
`localStorage` (один origin):
|
||
|
||
```ts
|
||
import { expect, test, type Page } from "@playwright/test";
|
||
import { login } from "./hub.ts";
|
||
|
||
type Box = { left: string; top: string; width: string; height: string };
|
||
const boxes = (page: Page) => page.locator(".board .card").evaluateAll((els) =>
|
||
Object.fromEntries(els.map((e) => {
|
||
const el = e as HTMLElement;
|
||
return [el.querySelector("a.key")?.textContent ?? "", { left: el.style.left, top: el.style.top, width: el.style.width, height: el.style.height }];
|
||
})) as Record<string, Box>);
|
||
const lanes = (page: Page) => page.locator(".board .lane-name").allTextContents();
|
||
|
||
test("раскладка новой доски совпадает со старой страницей", async ({ page }) => {
|
||
await login(page); // токен в localStorage общий для /app и /links
|
||
for (const mode of ["обычная", "по датам"]) {
|
||
if (mode === "по датам") await page.getByRole("button", { name: "▥ по датам" }).click();
|
||
await expect(page.locator(".board .card").first()).toBeVisible();
|
||
const fresh = { boxes: await boxes(page), lanes: await lanes(page), edges: await page.locator("svg.edges path").count() };
|
||
await page.screenshot({ path: `test-results/compare/new-${mode}.png`, fullPage: true });
|
||
await page.goto("/links");
|
||
await expect(page.locator(".board .card").first()).toBeVisible();
|
||
const old = { boxes: await boxes(page), lanes: await lanes(page), edges: await page.locator("#edges path").count() };
|
||
await page.screenshot({ path: `test-results/compare/old-${mode}.png`, fullPage: true });
|
||
expect(fresh.lanes).toEqual(old.lanes);
|
||
expect(fresh.edges).toBe(old.edges);
|
||
expect(Object.keys(fresh.boxes).sort()).toEqual(Object.keys(old.boxes).sort());
|
||
for (const k of Object.keys(old.boxes)) {
|
||
for (const p of ["left", "top", "width", "height"] as const) {
|
||
expect(Math.abs(parseFloat(fresh.boxes[k][p]) - parseFloat(old.boxes[k][p])), `${mode} ${k}.${p}`).toBeLessThanOrEqual(1);
|
||
}
|
||
}
|
||
await page.goto("/app/board");
|
||
}
|
||
});
|
||
```
|
||
|
||
Расхождение высоты карточек возможно из-за шапки карточки (значок «ⓘ» шире и может перенести метку спринта на вторую
|
||
строку): если сравнение падает только по `height` и только у карточек с меткой спринта — уменьшить `.open-card` (CSS) до
|
||
совпадения, а не ослаблять допуск. Скриншоты `test-results/compare/*.png` — посмотреть глазами (Read) и описать отличия в
|
||
отчёте задачи.
|
||
|
||
- [ ] **Step 4: Прогнать**
|
||
|
||
```bash
|
||
printf 'web/.e2e-hub/\n' >> .gitignore
|
||
cd web && npx playwright install chromium && npm run e2e
|
||
```
|
||
|
||
В `web/eslint.config.js` — добавить `.e2e-hub` в `ignores`.
|
||
|
||
Expected: все сценарии зелёные (login 2, board 5, issues 1, compare 1). Падения из-за поведения — чинить код в
|
||
`web/src` (это и есть смысл задачи), тесты не ослаблять; исправления — отдельными коммитами `fix(web): … (#21)`.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add .gitignore web/scripts/test-hub.sh web/scripts/seed-hub.py web/playwright.config.ts web/e2e web/eslint.config.js
|
||
git commit -m "test(web): тестовый хаб и e2e — вход, доска, задачи, сверка со старой страницей (#21)
|
||
|
||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 16: переключение `/` и `/links` на приложение, удаление `links.html`, документация
|
||
|
||
**Модель:** haiku (код и тексты готовы).
|
||
|
||
**Files:**
|
||
- Modify: `src/jira_hub/api.py` (удалить `PAGE`, `h_page`; добавить `h_root`; маршрут `^/(links)?$`; докстринг)
|
||
- Delete: `src/jira_hub/web/links.html`, `web/e2e/compare.spec.ts`
|
||
- Modify: `tests/test_api.py` (удалить `test_page_served_without_token`, `test_page_reads_hub_and_has_no_embedded_export`,
|
||
`_raw_get` если больше не нужен), `tests/test_web_app.py` (тест редиректа)
|
||
- Modify: `README.md`, `ARCHITECTURE.md`, `CLAUDE.md`
|
||
|
||
**Interfaces:**
|
||
- Consumes: всё предыдущее.
|
||
- Produces: `GET /` и `GET /links` → 302 `Location: /app/board`.
|
||
|
||
- [ ] **Step 1: Падающий тест редиректа** — в `tests/test_web_app.py`:
|
||
|
||
```python
|
||
@pytest.mark.parametrize("path", ["/", "/links"])
|
||
def test_old_board_addresses_redirect_to_app(app_hub, path):
|
||
status, r, body = _get(app_hub, path)
|
||
assert status == 302
|
||
assert r.getheader("Location") == "/app/board"
|
||
assert body == b""
|
||
|
||
|
||
def test_links_page_is_gone():
|
||
assert not hasattr(api, "PAGE")
|
||
assert not (api.WEB_APP.parent / "links.html").exists()
|
||
```
|
||
|
||
Run: `uv run pytest tests/test_web_app.py -q` → FAIL (200 вместо 302).
|
||
|
||
- [ ] **Step 2: Реализация** — `src/jira_hub/api.py`:
|
||
- удалить строку `PAGE = …` и функцию `h_page`;
|
||
- после `h_app` добавить:
|
||
|
||
```python
|
||
def h_root(hub, conn, m, q, body):
|
||
"""Старые адреса доски (/, /links) — на страницу доски веб-приложения."""
|
||
return 302, Raw("text/html; charset=utf-8", b"", {"Location": "/app/board", "Cache-Control": "no-cache"})
|
||
```
|
||
|
||
- в `ROUTES` заменить `("GET", r"^/(links)?$", h_page, None),` на `("GET", r"^/(links)?$", h_root, None),`;
|
||
- докстринг модуля, третья строка: `Без токена — статика без данных: веб-приложение (GET /app…) и перенаправление старых адресов доски (GET /, GET /links).`;
|
||
- докстринг `Raw`: `"""Не-JSON ответ обработчика (веб-приложение, перенаправление). headers=None — Cache-Control: no-cache."""`.
|
||
|
||
Удалить: `git rm src/jira_hub/web/links.html web/e2e/compare.spec.ts`. В `tests/test_api.py` удалить два теста страницы
|
||
и помощник `_raw_get`, если других использований нет (`grep -n _raw_get tests/`).
|
||
|
||
- [ ] **Step 3: Документация**
|
||
|
||
`CLAUDE.md`:
|
||
- раздел «Что это», конец: «Спеки: `openspec/specs/`; текущий change — `openspec/changes/web-app/`.»;
|
||
- раздел «Стек и ограничения», первая строка — дописать: «Веб-приложение — `web/` (React 19 + TypeScript + Vite,
|
||
TanStack Query, Vitest, Playwright; Node 24, npm); сборка в `src/jira_hub/web/app/` не в git.»;
|
||
- раздел «Команды»: строку про доску заменить на «Веб-приложение — `http://127.0.0.1:8790/app/` (доска — `/app/board`,
|
||
задачи — `/app/issues`; вход по клиентскому токену); `/` и `/links` перенаправляют на доску.» и добавить:
|
||
«`make web` — сборка приложения (после `git pull` на Mac mini, до перезапуска хаба); `make check` — pytest + lint и
|
||
тесты фронта; `cd web && npm run dev` — разработка с прокси на тестовый хаб `bash web/scripts/test-hub.sh` (:8791);
|
||
`cd web && npm run e2e` — Playwright; `cd web && npm run golden` — перегенерировать эталоны доски из
|
||
`links.html@3931a88` (только при смене фикстур).»;
|
||
- раздел «Тесты»: добавить «Фронт: golden-эталоны доски — `web/src/board/__fixtures__/*.expected.json` (их пишет
|
||
`web/scripts/golden-from-links.ts` из старой страницы); тесты рядом с кодом `*.test.ts(x)`; e2e — `web/e2e/` на
|
||
тестовом хабе.».
|
||
|
||
`ARCHITECTURE.md`:
|
||
- абзац про `links.html` заменить на:
|
||
«`web/` — веб-приложение хаба (React + TypeScript + Vite): вход по клиентскому токену, страницы «Доска» (`/app/board`)
|
||
и «Задачи» (`/app/issues`, `/app/issues/{key}`). Собирается `make web` в `src/jira_hub/web/app/`; `api` отдаёт сборку
|
||
без токена (`/app/assets/*` — файлы, прочие `/app…` — `index.html`, 503 без сборки), `/` и `/links` перенаправляет на
|
||
`/app/board`. Данные — только через HTTP API с клиентским токеном; `mirror.board` отдаёт зеркало без преобразований,
|
||
перевод в модель доски — в `web/src/board/model`. Логика доски — чистые модули `web/src/board/{model,analysis,layout}`
|
||
и `storage.ts` (ключи `localStorage` прежней страницы), отрисовка — `web/src/board/components`.»;
|
||
- таблица модулей, строка `api`: «… фоновый `tick`; раздача веб-приложения `/app…` (каталог `web/app` пакета, без токена)
|
||
и перенаправление `/`, `/links`; снимок `GET /board`»;
|
||
- «Границы» — добавить пункт: «Веб-приложение (`web/`) ходит в хаб только через HTTP API (`web/src/api/client.ts`);
|
||
словари `kind_labels`, `link_types`, `status_labels` берёт из `GET /settings` и своих копий не держит.»;
|
||
- «Открытые вопросы»: пункт про просмотрщик заменить на «Правки из UI в Jira (спринт, срок, связи, исполнитель) —
|
||
change `viewer-edit`; страницы очереди, агента и настроек — позже.».
|
||
|
||
`README.md`: раздел про доску связей (найти по «Доска связей» / `links.html`) заменить разделом «Веб-приложение» —
|
||
адреса, вход по токену (`uv run jira-hub tokens`), выкатка на Mac mini:
|
||
|
||
```
|
||
cd ~/projects/awp-ca/jira-hub
|
||
git pull
|
||
make web
|
||
launchctl kickstart -k gui/$(id -u)/ru.aepif.jira-hub
|
||
```
|
||
|
||
и абзац: «Без `make web` хаб на `/app…` отвечает 503 с подсказкой. Раскладка доски и вход сохраняются: приложение
|
||
читает те же ключи `localStorage`, что прежняя страница. Разработка: `bash web/scripts/test-hub.sh` (тестовый хаб
|
||
:8791 с синтетическими данными) и `cd web && npm run dev` (http://127.0.0.1:5173/app/).»
|
||
|
||
- [ ] **Step 4: Весь набор**
|
||
|
||
Run: `make check && cd web && npm run e2e`
|
||
Expected: pytest — 221 − 2 (удалённые тесты страницы) + 3 (редирект ×2, `links_page_is_gone`) = 222 passed; фронт
|
||
зелёный; e2e — 8 сценариев (сверка со старой страницей удалена вместе с ней).
|
||
|
||
Run: `rg -n "links\.html|PAGE\b|h_page" src tests web/src README.md ARCHITECTURE.md CLAUDE.md`
|
||
Expected: упоминаний нет (кроме `web/scripts/golden-from-links.ts` и комментариев с «links.html@3931a88» в `web/src` —
|
||
это ссылки на источник переноса, они остаются).
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add -A src/jira_hub/api.py tests web/e2e README.md ARCHITECTURE.md CLAUDE.md
|
||
git commit -m "feat(api): / и /links ведут в веб-приложение, links.html удалена; документация (#21)
|
||
|
||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
|
||
```
|