sdlc-hub/docs/superpowers/plans/2026-10-14-web-app-hub.md
Andrey Epifancev 0f2df4c929 docs: план web-app и решения этапа плана R-10, R-11 (#21)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 19:49:48 +04:00

5269 lines
283 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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

# 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>"
```