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

283 KiB
Raw Blame History

web-app — веб-приложение хаба на React: план реализации

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: хаб отдаёт по /app/ веб-приложение на React (вход по токену, «Доска», «Задачи»); доска связей перенесена из links.html без потери поведения и раскладок, M-1…M-7 jira-hub#20 закрыты; / и /links перенаправляют на /app/board.

Architecture: npm-проект web/ (React 19 + TS + Vite) собирается в src/jira_hub/web/app/ (не в git); хаб раздаёт сборку без токена (/app/assets/* — файлы, прочие /app… — index.html, 503 без сборки). Данные — только через существующий HTTP API с клиентским токеном. Логика доски — чистые TS-модули (web/src/board/{model,analysis,layout}, storage.ts), проверенные golden-эталонами, которые генерирует сама старая страница (ревизия 3931a88) в node:vm; отрисовка — React-компоненты, данные и опрос пакета — TanStack Query.

Tech Stack: Python 3.13, uv, pytest, stdlib http.server (хаб); Node 24, npm, React 19, TypeScript (strict), Vite, React Router, TanStack Query 5, Vitest + Testing Library (jsdom), ESLint (typescript-eslint), Playwright (e2e).

Spec: openspec/changes/web-app/ (proposal.md, design.md, specs/web-app/spec.md, specs/hub-api/spec.md).

Global Constraints

  • Рабочий каталог: /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/web-app (ветка feat/21-web-app). Все команды — с этим cwd (фронт — web/ внутри него). Основной checkout jira-hub/ (main) не правится; живой хаб :8790 не трогается.
  • codegraph: в codegraph_explore передавать projectPath = /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/web-app.
  • Комментарии, docstring, тексты интерфейса, сообщения ошибок и коммитов — по-русски; идентификаторы — английские.
  • Источник поведения доски — src/jira_hub/web/links.html ревизии 3931a88 (git show 3931a88:src/jira_hub/web/links.html); номера строк в плане — по этой ревизии. Перенос алгоритмов — построчный; переименование и упрощение допустимы только если golden-тесты остаются зелёными.
  • Зависимости npm ставятся только npm install --save-exact --before=2026-09-30 … (версии старше двух недель, без ^); package-lock.json в git; зависимостей времени выполнения ровно четыре: react, react-dom, react-router, @tanstack/react-query. У Python-пакета зависимостей не прибавляется.
  • Ключи localStorage — ровно как у links.html: токен jira-hub-token; общие настройки graph-<имя>; настройки доски graph-<имя>:board (pos, posdate, lanes, fold, sprint, who, colw); миграция migrateStore и старого graph-mode сохраняется.
  • Приложение — под /app/ (base: "/app/", basename: "/app"); маршруты /app/board, /app/issues, /app/issues/:key.
  • «Обновить из Jira» — ровно POST /refresh {"full": true, "author": "viewer"}; опрос GET /batches/{id} каждые 3000 мс; опрос прекращается на 404 и после 5 ошибок «хаб не отвечает» подряд.
  • Заметки — PUT /issues/{key}/note только с изменёнными полями и updated_by: "web"; очищенное поле — null.
  • Словари kind_labels, link_types, status_labels — только из GET /settings; в коде фронта их копий нет (EPICS, KIND, PRIO, JIRA_URL — константы оформления, остаются в коде).
  • Фикстуры — синтетические (R-4): ключи AURORA-9xxx, вымышленные ФИО; данных живого зеркала в git нет.
  • Тесты: uv run pytest -q (до начала: 206 passed) и cd web && npm run lint && npm test; зелёным считается только весь набор; итог — make check.
  • Каждая задача заканчивается коммитом с заголовком на русском, суффиксом (#21) и строкой Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>.
  • Решения исполнителя без обсуждения — в openspec/changes/web-app/design.md, раздел ## Decisions made without discussion, со следующим свободным маркером (занято R-1…R-11).
  • Клиентский токен живого хаба не печатать и не вводить в браузер: проверки — на тестовом хабе :8791.
  • Модель исполнителя указана у каждой задачи (SDD Model Selection: готовый код — haiku, перенос и интеграция — sonnet).

Review Focus

  1. Токен вставлен с пробелом, переводом строки или кириллицей: fetch с таким заголовком бросает TypeError, и приложение не должно сообщать «хаб не отвечает» — экран входа говорит «в токене недопустимые символы» (как links.html, строка 1080). Тест — Task 3, LoginScreen.test.tsx.
  2. Путь к файлу сборки с обходом каталога (/app/assets/../../api.py, %2e%2e, %2F) — 404, а не содержимое файла; запросы шлёт http.client без нормализации пути. Тест — Task 1.
  3. Поздний ответ на один запрос перебивает более новый: открыта карточка AURORA-9001, затем быстро AURORA-9002 — на экране должна быть 9002; ответ «Сохранить» заметки после ухода в другую карточку не пишет её в форму новой. Ключи запросов TanStack Query включают key задачи. Тесты — Task 14.
  4. Пустые словари в настройках (kind_labels/link_types = {} на чистой базе): доска показывает «в настройках хаба нет …», а не пустую доску без связей. Тест — Task 11, BoardPage.test.tsx.
  5. localStorage недоступен (приватный режим, SecurityError на любом обращении): вход работает с токеном в памяти, доска строится, раскладка просто не сохраняется — без исключений. Тесты — Task 3 (token.test.ts) и Task 9 (storage.test.ts).

Структура файлов

Makefile                                   make web, make check
.gitignore                                 + src/jira_hub/web/app/, web/test-results/, web/playwright-report/
src/jira_hub/api.py                        раздача /app…, Raw.headers, редирект / и /links (Task 16)
tests/test_web_app.py                      раздача приложения
tests/test_mirror.py                       + M-7
web/
  package.json, package-lock.json, tsconfig.json, vite.config.ts, eslint.config.js, index.html, playwright.config.ts
  scripts/golden-from-links.ts             генератор эталонов из links.html@3931a88 (node:vm)
  scripts/test-hub.sh                      тестовый хаб :8791 для разработки и e2e
  e2e/*.spec.ts                            Playwright
  src/main.tsx, src/App.tsx                вход, провайдеры, маршрутизатор
  src/test/setup.ts, src/test/canon.ts     настройка Vitest, нормализация для golden
  src/api/types.ts, client.ts, queries.ts  типы API, клиент, хуки запросов
  src/auth/token.ts, AuthContext.tsx, LoginScreen.tsx
  src/shell/Layout.tsx, ErrorBoundary.tsx, NotFound.tsx
  src/styles/theme.css, base.css, controls.css
  src/board/constants.ts, types.ts, dicts.ts
  src/board/model/{util,csv,fromHub,fromCsv,fromJson}.ts
  src/board/analysis/{visible,critical,problems,facets}.ts
  src/board/layout/{layout,edges,align}.ts
  src/board/storage.ts, exportJson.ts
  src/board/useBoardSource.ts, useBoardPrefs.ts
  src/board/components/*.tsx, board.css
  src/board/__fixtures__/*                 синтетические фикстуры и эталоны *.expected.json
  src/issues/IssuesPage.tsx, IssuePage.tsx, NoteForm.tsx, issues.css

Общие типы (определяются в задачах, используются дальше — имена не менять):

  • web/src/api/types.ts (Task 3): BoardIssue, BoardLink, SyncRun, BoardSnapshot, HubSettings, IssueRow, IssueDetail, Note, NoteFields, IssueCommand, Batch, BatchStatus, AgentView, CreatedBatch.
  • web/src/api/client.ts (Task 3): AuthError, HubDownError, ApiError, api.get/post/put.
  • web/src/board/types.ts (Task 6): BoardNode, Link, BoardModel, Dicts, Filters, VisNode, VisLink, Visible, Problems, JsonImport, SavedBox.
  • web/src/board/layout/layout.ts (Task 8): Rect, RelBox, Layout, LaneGeom, Placed, DateCol.

Task 1: хаб раздаёт веб-приложение по /app… (и тест M-7)

Модель: haiku (код готов).

Files:

  • Modify: src/jira_hub/api.py (докстринг модуля, константы после PAGE, Raw, Hub.__init__, новый обработчик h_app после h_page, ROUTES, Handler._send)
  • Create: tests/test_web_app.py
  • Modify: tests/test_mirror.py (новый тест после test_board_last_sync_is_last_successful_full_refresh)

Interfaces:

  • Produces: api.WEB_APP: Path — каталог сборки по умолчанию; api.Hub(db_path, backups_dir=None, web_dir=None) — hub.web_dir: Path; api.Raw(content_type, data, headers=None) — headers=None означает Cache-Control: no-cache; маршрут GET ^/app(?:/(.*))?$ без токена. GET / и GET /links пока отдают links.html (переключение — Task 16).

  • Step 1: Написать падающие тесты раздачи

tests/test_web_app.py:

"""Раздача веб-приложения: 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-9004 epic = «Гудвилл», у AURORA-9008 — «AURORA-9999», у AURORA-9006 — «Без эпика»;
  • scenarios.crit.visible — crit: true у AURORA-9003, 9001, 9002, 9005;
  • scenarios.default.visible.problems — непустые dates, unassigned, late, hand;
  • scenarios.empty.emptyReason начинается с «Фильтры не оставили ни одной задачи»;
  • scenarios.by-date.layout.dateCols непустой; scenarios.saved-folded.layout.lanes содержит folded: true у «Гудвилл»;
  • jira-bad.expected.json → error — «не нашёл колонки «Ключ проблемы» и «Тема»…»;
  • storage.expected.json → migrate-largest перенёс graph-pos:board и graph-lanes:board из new.csv.

Если какой-то пункт не выполняется — сначала поправить фикстуру (не генератор и не эталон руками) и перегенерировать.

  • Step 4: Commit
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–331 ARROWS, 838–849 drawEdges, 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>"

Модель: haiku (код и тексты готовы).

Files:

  • Modify: src/jira_hub/api.py (удалить PAGE, h_page; добавить h_root; маршрут ^/(links)?$; докстринг)
  • Delete: src/jira_hub/web/links.html, web/e2e/compare.spec.ts
  • Modify: tests/test_api.py (удалить test_page_served_without_token, test_page_reads_hub_and_has_no_embedded_export, _raw_get если больше не нужен), tests/test_web_app.py (тест редиректа)
  • Modify: README.md, ARCHITECTURE.md, CLAUDE.md

Interfaces:

  • Consumes: всё предыдущее.

  • Produces: GET / и GET /links → 302 Location: /app/board.

  • Step 1: Падающий тест редиректа — в tests/test_web_app.py:

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