283 KiB
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/внутри него). Основной checkoutjira-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
- Токен вставлен с пробелом, переводом строки или кириллицей:
fetchс таким заголовком бросаетTypeError, и приложение не должно сообщать «хаб не отвечает» — экран входа говорит «в токене недопустимые символы» (какlinks.html, строка 1080). Тест — Task 3,LoginScreen.test.tsx. - Путь к файлу сборки с обходом каталога (
/app/assets/../../api.py,%2e%2e,%2F) — 404, а не содержимое файла; запросы шлётhttp.clientбез нормализации пути. Тест — Task 1. - Поздний ответ на один запрос перебивает более новый: открыта карточка AURORA-9001, затем быстро AURORA-9002 — на экране
должна быть 9002; ответ «Сохранить» заметки после ухода в другую карточку не пишет её в форму новой. Ключи запросов
TanStack Query включают
keyзадачи. Тесты — Task 14. - Пустые словари в настройках (
kind_labels/link_types={}на чистой базе): доска показывает «в настройках хаба нет …», а не пустую доску без связей. Тест — Task 11,BoardPage.test.tsx. 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:
"""Раздача веб-приложения: 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:
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
Докстринг модуля — третья строка заменяется:
"""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 = …:
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:
class Raw(NamedTuple):
"""Не-JSON ответ обработчика (страница доски, веб-приложение). headers=None — Cache-Control: no-cache."""
content_type: str
data: bytes
headers: dict | None = None
Hub.__init__ — сигнатура и первая строка тела:
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:
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),:
("GET", r"^/app(?:/(.*))?$", h_app, None),
Handler._send — первая ветка:
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
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и зависимости
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:
{
"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:
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:
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:
<!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:
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:
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:
// нормализация значений для сравнения с 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:
export function App() {
return <h1>Хаб Jira</h1>;
}
web/src/main.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:
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 (отступы — табуляция):
.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
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:
// ответы 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:
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:
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:
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:
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:
// клиентский токен хаба: 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:
import { useSyncExternalStore } from "react";
import { getAuth, subscribe, type AuthSnapshot } from "./token.ts";
export const useAuth = (): AuthSnapshot => useSyncExternalStore(subscribe, getAuth);
web/src/api/client.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:
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:
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):
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:
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:
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
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:
/* оболочка: верхняя панель разделов */
.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:
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:
// тема интерфейса: "" — системная, 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:
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:
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:
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:
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):
// 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:
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:
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 добавить применение сохранённой темы:
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
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 (как в настройках живого хаба):
{
"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, совпадающие сроки, блокер позже
зависимой, блокер без исполнителя, не начатый блокер у близкого срока, передачи, цепочка для критического пути, четыре
спринта и «без спринта», логин вместо ФИО):
{
"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:
{"generated_at": "2026-10-14T10:00:00+00:00", "last_sync": null, "issues": [], "links": []}
web/src/board/__fixtures__/scenarios.json — наборы фильтров и сохранённого состояния доски; отсутствующие поля —
значения по умолчанию (флаги false, множества пустые, хранилище пустое):
[
{"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 — часть проверки разбора):
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, битая раскладка, дубль ключа,
заглушка, неизвестный тип связи, связь на ключ вне файла):
{"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:
{"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()):
[
{"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:
// разовый генератор 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-9004epic= «Гудвилл», у 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
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:
// модель доски связей; поля и их имена — как у 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:
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:
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:
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:
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
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(общий для тестов анализа и раскладки; в тестовый файл не кладётся, иначе импорт запустит чужие тесты):
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:
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
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:
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:
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
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:
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:
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
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:
// 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 с)]` |
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:
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:
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:
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:
// источник данных доски: снимок хаба (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
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:
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) целиком. Добавить в конец:
/* «Открыть карточку» в заголовке карточки (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:
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):
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–331ARROWS, 838–849drawEdges, 860–863 направляющие):
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):
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):
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
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
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):
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:
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
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:
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:
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):
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):
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):
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):
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):
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):
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(заменить временную страницу):
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
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:
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:
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:
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:
.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
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:
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
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
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 — дописать:
.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
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):
"""Заполнить зеркало тестового хаба фикстурой доски и записать токены рядом с базой (для 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:
#!/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:
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 — общие помощники:
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:
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:
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:
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):
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: Прогнать
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
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→ 302Location: /app/board. -
Step 1: Падающий тест редиректа — в
tests/test_web_app.py:
@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добавить:
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
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>"