diff --git a/docs/superpowers/plans/2026-10-14-web-app-hub.md b/docs/superpowers/plans/2026-10-14-web-app-hub.md new file mode 100644 index 0000000..662f909 --- /dev/null +++ b/docs/superpowers/plans/2026-10-14-web-app-hub.md @@ -0,0 +1,5268 @@ +# 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 `. +- Решения исполнителя без обсуждения — в `openspec/changes/web-app/design.md`, раздел + `## Decisions made without discussion`, со следующим свободным маркером (занято R-1…R-11). +- Клиентский токен **живого** хаба не печатать и не вводить в браузер: проверки — на тестовом хабе `:8791`. +- Модель исполнителя указана у каждой задачи (SDD Model Selection: готовый код — haiku, перенос и интеграция — sonnet). + +## Review Focus + +1. Токен вставлен с пробелом, переводом строки или кириллицей: `fetch` с таким заголовком бросает `TypeError`, и + приложение не должно сообщать «хаб не отвечает» — экран входа говорит «в токене недопустимые символы» (как + `links.html`, строка 1080). Тест — Task 3, `LoginScreen.test.tsx`. +2. Путь к файлу сборки с обходом каталога (`/app/assets/../../api.py`, `%2e%2e`, `%2F`) — 404, а не содержимое файла; + запросы шлёт `http.client` без нормализации пути. Тест — Task 1. +3. Поздний ответ на один запрос перебивает более новый: открыта карточка AURORA-9001, затем быстро AURORA-9002 — на экране + должна быть 9002; ответ «Сохранить» заметки после ухода в другую карточку не пишет её в форму новой. Ключи запросов + TanStack Query включают `key` задачи. Тесты — Task 14. +4. Пустые словари в настройках (`kind_labels`/`link_types` = `{}` на чистой базе): доска показывает «в настройках хаба нет + …», а не пустую доску без связей. Тест — Task 11, `BoardPage.test.tsx`. +5. `localStorage` недоступен (приватный режим, `SecurityError` на любом обращении): вход работает с токеном в памяти, + доска строится, раскладка просто не сохраняется — без исключений. Тесты — Task 3 (`token.test.ts`) и Task 9 + (`storage.test.ts`). + +--- + +## Структура файлов + +``` +Makefile make web, make check +.gitignore + src/jira_hub/web/app/, web/test-results/, web/playwright-report/ +src/jira_hub/api.py раздача /app…, Raw.headers, редирект / и /links (Task 16) +tests/test_web_app.py раздача приложения +tests/test_mirror.py + M-7 +web/ + package.json, package-lock.json, tsconfig.json, vite.config.ts, eslint.config.js, index.html, playwright.config.ts + scripts/golden-from-links.ts генератор эталонов из links.html@3931a88 (node:vm) + scripts/test-hub.sh тестовый хаб :8791 для разработки и e2e + e2e/*.spec.ts Playwright + src/main.tsx, src/App.tsx вход, провайдеры, маршрутизатор + src/test/setup.ts, src/test/canon.ts настройка Vitest, нормализация для golden + src/api/types.ts, client.ts, queries.ts типы API, клиент, хуки запросов + src/auth/token.ts, AuthContext.tsx, LoginScreen.tsx + src/shell/Layout.tsx, ErrorBoundary.tsx, NotFound.tsx + src/styles/theme.css, base.css, controls.css + src/board/constants.ts, types.ts, dicts.ts + src/board/model/{util,csv,fromHub,fromCsv,fromJson}.ts + src/board/analysis/{visible,critical,problems,facets}.ts + src/board/layout/{layout,edges,align}.ts + src/board/storage.ts, exportJson.ts + src/board/useBoardSource.ts, useBoardPrefs.ts + src/board/components/*.tsx, board.css + src/board/__fixtures__/* синтетические фикстуры и эталоны *.expected.json + src/issues/IssuesPage.tsx, IssuePage.tsx, NoteForm.tsx, issues.css +``` + +Общие типы (определяются в задачах, используются дальше — имена не менять): + +- `web/src/api/types.ts` (Task 3): `BoardIssue`, `BoardLink`, `SyncRun`, `BoardSnapshot`, `HubSettings`, `IssueRow`, + `IssueDetail`, `Note`, `NoteFields`, `IssueCommand`, `Batch`, `BatchStatus`, `AgentView`, `CreatedBatch`. +- `web/src/api/client.ts` (Task 3): `AuthError`, `HubDownError`, `ApiError`, `api.get/post/put`. +- `web/src/board/types.ts` (Task 6): `BoardNode`, `Link`, `BoardModel`, `Dicts`, `Filters`, `VisNode`, `VisLink`, + `Visible`, `Problems`, `JsonImport`, `SavedBox`. +- `web/src/board/layout/layout.ts` (Task 8): `Rect`, `RelBox`, `Layout`, `LaneGeom`, `Placed`, `DateCol`. + +--- +### Task 1: хаб раздаёт веб-приложение по `/app…` (и тест M-7) + +**Модель:** haiku (код готов). + +**Files:** +- Modify: `src/jira_hub/api.py` (докстринг модуля, константы после `PAGE`, `Raw`, `Hub.__init__`, новый обработчик + `h_app` после `h_page`, `ROUTES`, `Handler._send`) +- Create: `tests/test_web_app.py` +- Modify: `tests/test_mirror.py` (новый тест после `test_board_last_sync_is_last_successful_full_refresh`) + +**Interfaces:** +- Produces: `api.WEB_APP: Path` — каталог сборки по умолчанию; `api.Hub(db_path, backups_dir=None, web_dir=None)` — + `hub.web_dir: Path`; `api.Raw(content_type, data, headers=None)` — `headers=None` означает `Cache-Control: no-cache`; + маршрут `GET ^/app(?:/(.*))?$` без токена. `GET /` и `GET /links` пока отдают `links.html` (переключение — Task 16). + +- [ ] **Step 1: Написать падающие тесты раздачи** + +`tests/test_web_app.py`: + +```python +"""Раздача веб-приложения: GET /app… без токена (specs/hub-api «Раздача веб-приложения»).""" +import http.client +import threading +from urllib.parse import urlparse + +import pytest + +from jira_hub import api + + +def _get(base, path): + """Сырой GET: http.client не нормализует путь (../ и %2e%2e доходят до хаба как есть).""" + u = urlparse(base) + c = http.client.HTTPConnection(u.hostname, u.port, timeout=10) + try: + c.request("GET", path) + r = c.getresponse() + return r.status, r, r.read() + finally: + c.close() + + +def _serve(tmp_path, web_dir): + h = api.Hub(tmp_path / "hub.sqlite", backups_dir=tmp_path / "backups", web_dir=web_dir) + srv = api.make_server(h, "127.0.0.1", 0) + th = threading.Thread(target=srv.serve_forever, daemon=True) + th.start() + return srv, th, f"http://127.0.0.1:{srv.server_port}" + + +@pytest.fixture +def app_hub(tmp_path): + web = tmp_path / "app" + (web / "assets").mkdir(parents=True) + (web / "index.html").write_text("
", 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"
" + + +@pytest.mark.parametrize("path", ["/app/assets/../../secret.txt", "/app/assets/%2e%2e/%2e%2e/secret.txt", + "/app/assets/..%2F..%2Fsecret.txt", "/app/assets/missing.js", "/app/assets/"]) +def test_asset_outside_or_missing_is_404(app_hub, path): + status, _, body = _get(app_hub, path) + assert status == 404 + assert "секрет".encode("utf-8") not in body + + +def test_not_built_is_503_with_hint(tmp_path): + srv, th, base = _serve(tmp_path, tmp_path / "no-build") + try: + for path in ("/app/board", "/app/assets/index-abc123.js"): + status, r, body = _get(base, path) + assert status == 503 + assert r.getheader("Content-Type") == "text/html; charset=utf-8" + assert "make web".encode("utf-8") in body + finally: + srv.shutdown() + srv.server_close() + th.join(5) + + +def test_other_paths_are_not_app(app_hub): + status, _, _ = _get(app_hub, "/application") + assert status == 404 # не префикс /app/, обычный 404 API + status, _, _ = _get(app_hub, "/board") + assert status == 401 # API по-прежнему под токеном + + +def test_default_web_dir_is_package_build(tmp_path): + h = api.Hub(tmp_path / "hub.sqlite", backups_dir=tmp_path / "backups") + assert h.web_dir == api.WEB_APP + assert api.WEB_APP.parts[-3:] == ("jira_hub", "web", "app") +``` + +- [ ] **Step 2: Тест M-7 — `mirror.board` внутри открытой транзакции** + +В `tests/test_mirror.py` после `test_board_last_sync_is_last_successful_full_refresh`: + +```python +def test_board_inside_open_transaction_keeps_it_open(): + """M-7 #20: вызов внутри уже открытой транзакции не открывает и не закрывает свою.""" + conn = db.connect(":memory:") + mirror.apply_state(conn, [issue("AURORA-1620")], []) + with db.transaction(conn): + assert conn.in_transaction + snap = mirror.board(conn, BASE) + assert conn.in_transaction + assert not conn.in_transaction + assert [i["key"] for i in snap["issues"]] == ["AURORA-1620"] +``` + +- [ ] **Step 3: Запустить — новые тесты раздачи падают, M-7 проходит** + +Run: `uv run pytest tests/test_web_app.py tests/test_mirror.py -q` +Expected: FAIL в `test_web_app.py` (`TypeError: Hub.__init__() got an unexpected keyword argument 'web_dir'`); +`test_board_inside_open_transaction_keeps_it_open` — PASS (код уже верный, тест закрывает пробел покрытия). + +- [ ] **Step 4: Реализация в `src/jira_hub/api.py`** + +Докстринг модуля — третья строка заменяется: + +```python +"""HTTP API хаба (stdlib http.server, поток на запрос, соединение SQLite на запрос). +Маршруты /agent/* — агентский токен, остальные — клиентский (specs/hub-api/spec.md). +Без токена — статика без данных: страница доски (GET /, GET /links) и веб-приложение (GET /app…).""" +``` + +Импорт: `from urllib.parse import parse_qs, unquote, urlparse`. + +После строки `PAGE = …`: + +```python +WEB_APP = Path(__file__).parent / "web" / "app" # сборка веб-приложения (make web, не в git); читается на каждый запрос +ASSET_TYPES = {".js": "text/javascript; charset=utf-8", ".css": "text/css; charset=utf-8", ".svg": "image/svg+xml", + ".png": "image/png", ".ico": "image/x-icon", ".woff2": "font/woff2", ".map": "application/json"} +ASSET_CACHE = {"Cache-Control": "public, max-age=31536000, immutable"} # имена файлов сборки содержат хэш (R-3) +NOT_BUILT = ("Хаб Jira

Веб-приложение не собрано: выполните " + "make web в каталоге jira-hub и перезапустите хаб.

").encode("utf-8") +``` + +`Raw`: + +```python +class Raw(NamedTuple): + """Не-JSON ответ обработчика (страница доски, веб-приложение). headers=None — Cache-Control: no-cache.""" + content_type: str + data: bytes + headers: dict | None = None +``` + +`Hub.__init__` — сигнатура и первая строка тела: + +```python + def __init__(self, db_path, backups_dir=None, web_dir=None): + self.db_path = Path(db_path) + self.web_dir = Path(web_dir) if web_dir else WEB_APP +``` + +После `h_page`: + +```python +def h_app(hub, conn, m, q, body): + """Веб-приложение: файлы assets/ сборки как есть, любой другой путь /app… — index.html (маршруты на клиенте). + Путь файла после разрешения обязан лежать внутри assets/, иначе 404; без сборки — 503 с подсказкой.""" + index = hub.web_dir / "index.html" + if not index.is_file(): + return 503, Raw("text/html; charset=utf-8", NOT_BUILT) + rest = unquote(m.group(1) or "") + if rest.startswith("assets/"): + assets = (hub.web_dir / "assets").resolve() + f = (hub.web_dir / rest).resolve() + if not f.is_relative_to(assets) or not f.is_file(): + raise ApiError(404, "not_found", "/app/" + rest) + return 200, Raw(ASSET_TYPES.get(f.suffix, "application/octet-stream"), f.read_bytes(), ASSET_CACHE) + return 200, Raw("text/html; charset=utf-8", index.read_bytes()) +``` + +`ROUTES` — после строки `("GET", r"^/(links)?$", h_page, None),`: + +```python + ("GET", r"^/app(?:/(.*))?$", h_app, None), +``` + +`Handler._send` — первая ветка: + +```python + if isinstance(payload, Raw): + ctype, data = payload.content_type, payload.data + extra = payload.headers if payload.headers is not None else {"Cache-Control": "no-cache"} +``` + +- [ ] **Step 5: Запустить весь набор** + +Run: `uv run pytest -q` +Expected: PASS, 206 + 15 новых (`test_web_app.py` — 14 с параметризацией, `test_mirror.py` — 1) = 221 passed. + +- [ ] **Step 6: Commit** + +```bash +git add src/jira_hub/api.py tests/test_web_app.py tests/test_mirror.py +git commit -m "feat(api): раздача веб-приложения по /app… без токена, 503 без сборки; тест M-7 (#21) + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 2: каркас `web/` — Vite, TypeScript, ESLint, Vitest, Makefile + +**Модель:** sonnet (установка зависимостей и сверка конфигов с установленными версиями). + +**Files:** +- Create: `web/package.json`, `web/package-lock.json` (npm), `web/tsconfig.json`, `web/vite.config.ts`, + `web/eslint.config.js`, `web/index.html`, `web/src/main.tsx`, `web/src/App.tsx`, `web/src/App.test.tsx`, + `web/src/test/setup.ts`, `web/src/test/canon.ts`, `web/src/test/canon.test.ts`, `web/src/vite-env.d.ts`, `Makefile` +- Modify: `.gitignore` + +**Interfaces:** +- Produces: `npm run dev|build|lint|test|e2e|golden`; `make web`, `make check`; `canon(v: unknown): unknown` в + `web/src/test/canon.ts` (нормализация для сравнения с эталоном; работает с объектами другого realm — `node:vm`). + +- [ ] **Step 1: `package.json` и зависимости** + +```bash +mkdir -p web/src/test web/scripts web/e2e +cd web +cat > package.json <<'EOF' +{ + "name": "jira-hub-web", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit -p . && vite build", + "lint": "tsc --noEmit -p . && eslint .", + "test": "vitest run", + "e2e": "playwright test", + "golden": "node scripts/golden-from-links.ts" + } +} +EOF +npm install --save-exact --before=2026-09-30 react react-dom react-router @tanstack/react-query +npm install --save-exact --save-dev --before=2026-09-30 typescript vite @vitejs/plugin-react vitest jsdom \ + @testing-library/react @testing-library/dom @testing-library/user-event @testing-library/jest-dom \ + @types/react @types/react-dom @types/node eslint @eslint/js typescript-eslint eslint-plugin-react-hooks globals \ + @playwright/test +cd .. +``` + +Проверить: в `web/package.json` у всех зависимостей версии без `^`/`~`; в `dependencies` ровно четыре пакета. Если +`npm install` сообщает конфликт peer-зависимостей — взять предыдущую минорную версию проблемного dev-пакета с тем же +`--before` (не `--force`, не `--legacy-peer-deps`) и записать выбор в design.md (следующий маркер R-n). + +- [ ] **Step 2: Конфиги** + +`web/tsconfig.json`: + +```json +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "resolveJsonModule": true, + "isolatedModules": true, + "erasableSyntaxOnly": true, + "allowImportingTsExtensions": true, + "noEmit": true, + "skipLibCheck": true, + "types": ["node", "vite/client", "@testing-library/jest-dom"] + }, + "include": ["src", "scripts", "e2e", "vite.config.ts", "playwright.config.ts"] +} +``` + +`erasableSyntaxOnly` нужен, чтобы `scripts/*.ts` запускались Node 24 без сборки (type stripping): без `enum`, +`namespace`, параметров-свойств конструктора. + +`web/vite.config.ts`: + +```ts +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vitest/config"; + +// сервер разработки: приложение под /app/, маршруты API — прокси на хаб (по умолчанию тестовый :8791, R-5) +const hub = process.env.JIRA_HUB_URL ?? "http://127.0.0.1:8791"; +const API = ["/board", "/issues", "/settings", "/agent", "/refresh", "/batches", "/commands", "/sync-runs"]; + +export default defineConfig({ + base: "/app/", + plugins: [react()], + build: { outDir: "../src/jira_hub/web/app", emptyOutDir: true }, + server: { host: "127.0.0.1", port: 5173, proxy: Object.fromEntries(API.map((p) => [p, { target: hub }])) }, + test: { + environment: "jsdom", + setupFiles: ["./src/test/setup.ts"], + include: ["src/**/*.test.{ts,tsx}"], + restoreMocks: true, + unstubGlobals: true, + }, +}); +``` + +`web/eslint.config.js`: + +```js +import js from "@eslint/js"; +import reactHooks from "eslint-plugin-react-hooks"; +import globals from "globals"; +import tseslint from "typescript-eslint"; + +export default tseslint.config( + { ignores: ["node_modules", "playwright-report", "test-results"] }, + js.configs.recommended, + ...tseslint.configs.recommended, + { + files: ["**/*.{ts,tsx,js}"], + languageOptions: { globals: { ...globals.browser, ...globals.node } }, + plugins: { "react-hooks": reactHooks }, + rules: { "react-hooks/rules-of-hooks": "error", "react-hooks/exhaustive-deps": "error" }, + }, +); +``` + +`web/index.html`: + +```html + + + + + + Хаб Jira + + + + +
+ + + +``` + +`web/src/vite-env.d.ts`: `/// ` + +`web/src/test/setup.ts`: + +```ts +import "@testing-library/jest-dom/vitest"; +import { cleanup } from "@testing-library/react"; +import { afterEach } from "vitest"; + +afterEach(() => { + cleanup(); + localStorage.clear(); +}); +``` + +- [ ] **Step 3: `canon` — тест и реализация** + +`web/src/test/canon.test.ts`: + +```ts +import vm from "node:vm"; +import { describe, expect, it } from "vitest"; +import { canon } from "./canon.ts"; + +describe("canon", () => { + it("сортирует ключи объектов и выбрасывает undefined", () => { + expect(JSON.stringify(canon({ b: 1, a: { d: undefined, c: [2, 1] } }))).toBe('{"a":{"c":[2,1]},"b":1}'); + }); + it("Set — отсортированный массив, в том числе из другого realm", () => { + const foreign = vm.runInNewContext('new Set(["b", "a"])'); + expect(canon(foreign)).toEqual(["a", "b"]); + expect(canon(new Set([{ k: 2 }, { k: 1 }]))).toEqual([{ k: 1 }, { k: 2 }]); + }); +}); +``` + +`web/src/test/canon.ts`: + +```ts +// нормализация значений для сравнения с golden-эталоном: ключи объектов по алфавиту, Set — отсортированный массив, +// undefined-поля выбрасываются (как в JSON). Set распознаётся по тегу: объекты из node:vm — из другого realm +const isSet = (v: unknown): v is Set => 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; + return Object.fromEntries(Object.keys(o).sort().filter((k) => o[k] !== undefined).map((k) => [k, canon(o[k])])); + } + return v; +} +``` + +- [ ] **Step 4: Заглушка приложения и её тест** + +`web/src/App.tsx`: + +```tsx +export function App() { + return

Хаб Jira

; +} +``` + +`web/src/main.tsx`: + +```tsx +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { App } from "./App.tsx"; + +createRoot(document.getElementById("root")!).render( + + + , +); +``` + +`web/src/App.test.tsx`: + +```tsx +import { render, screen } from "@testing-library/react"; +import { expect, it } from "vitest"; +import { App } from "./App.tsx"; + +it("показывает заголовок", () => { + render(); + expect(screen.getByRole("heading", { name: "Хаб Jira" })).toBeInTheDocument(); +}); +``` + +- [ ] **Step 5: `Makefile` и `.gitignore`** + +`Makefile` (отступы — табуляция): + +```make +.PHONY: web check + +# сборка веб-приложения в src/jira_hub/web/app/ (каталог не в git; хаб читает его на каждый запрос) +web: + cd web && npm ci && npm run build + +# весь набор проверок: хаб и фронт; зелёным считается только всё вместе +check: + uv run pytest -q + cd web && npm run lint && npm test +``` + +В `.gitignore` дописать: + +``` +src/jira_hub/web/app/ +web/test-results/ +web/playwright-report/ +``` + +- [ ] **Step 6: Проверки** + +Run: `cd web && npm run lint && npm test && cd .. && make web && ls src/jira_hub/web/app src/jira_hub/web/app/assets && git status --porcelain` +Expected: lint без ошибок; `2 passed` файла тестов (3 теста); в сборке `index.html` и `assets/index-*.js`; +`git status` показывает только новые исходники (`web/…`, `Makefile`, `.gitignore`), файлов сборки и `node_modules` в нём нет. + +Run: `make check` +Expected: pytest 221 passed, фронт зелёный. + +- [ ] **Step 7: Commit** + +```bash +git add Makefile .gitignore web/package.json web/package-lock.json web/tsconfig.json web/vite.config.ts \ + web/eslint.config.js web/index.html web/src +git commit -m "chore(web): каркас веб-приложения — Vite, TypeScript, ESLint, Vitest, make web/check (#21) + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- +### 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(path, params?)`, `api.post(path, body?)`, `api.put(path, body)`, `checkToken(token): Promise`. + - `queryClient.ts`: `makeQueryClient(): QueryClient` (401 в любом запросе → `clearToken("bad")`). + - `queries.ts`: `qk`, `useSettings()`, `useBoard()`, `useIssue(key)`, `useIssues(filters)`, `useSaveNote(key)`, + `type IssueFilters`, `ISSUE_FILTER_NAMES`, `ISSUE_PAGE = 50`. + - `AuthGate` — показывает `LoginScreen`, пока нет токена; иначе детей. + - тестовые помощники: `json(status, body): Response`, `mockFetch(handler)`, `renderWithProviders(ui, {route?, path?})`. + +- [ ] **Step 1: Типы API** — `web/src/api/types.ts`: + +```ts +// ответы HTTP API хаба (openspec/specs/hub-api); поля — как отдаёт хаб, без переименований + +export interface BoardIssue { + key: string; summary: string | null; issue_type: string | null; status: string | null; priority: string | null; + assignee: string | null; sprint: string | null; duedate: string | null; planned_start: string | null; + planned_end: string | null; epic_key: string | null; labels: string[]; neighbor: boolean; + fact_status: string | null; work_kind: string | null; +} +export interface BoardLink { source: string; target: string; link_type: string } +export interface SyncRun { started_at: string; finished_at: string; issues_count: number } +export interface BoardSnapshot { generated_at: string; last_sync: SyncRun | null; issues: BoardIssue[]; links: BoardLink[] } + +// словари настроек: kind_labels — вид работы → метка Jira, link_types — логический тип → имя типа связи Jira, +// status_labels — статус по факту → метка Jira +export interface HubSettings { + kind_labels?: Record; + link_types?: Record; + status_labels?: Record; + [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>; +export type Note = Record & { 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; remaining_seconds: number; commands?: BatchCommand[]; +} +export interface AgentView { online: boolean; last_seen_at: string | null; warning: string | null } +export interface CreatedBatch { id: number; estimate_seconds: number; count: number } +``` + +- [ ] **Step 2: Тестовые помощники** + +`web/src/test/fetchMock.ts`: + +```ts +import { vi } from "vitest"; + +export function json(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } }); +} + +// подмена fetch: handler получает метод, URL (относительно http://hub.test) и разобранное тело +export function mockFetch(handler: (method: string, url: URL, body: unknown) => Response | Promise) { + const fn = vi.fn(async (input: RequestInfo | URL, init: RequestInit = {}) => { + const url = new URL(String(input), "http://hub.test"); + const body = typeof init.body === "string" ? JSON.parse(init.body) : undefined; + return handler(init.method ?? "GET", url, body); + }); + vi.stubGlobal("fetch", fn); + return fn; +} +``` + +`web/src/test/render.tsx`: + +```tsx +import { QueryClientProvider } from "@tanstack/react-query"; +import { render } from "@testing-library/react"; +import type { ReactElement } from "react"; +import { createMemoryRouter, RouterProvider } from "react-router"; +import { makeQueryClient } from "../api/queryClient.ts"; + +// компонент под QueryClient и data-роутером в памяти: path — шаблон маршрута (для useParams), route — открытый адрес +export function renderWithProviders(ui: ReactElement, { route = "/", path = "*" }: { route?: string; path?: string } = {}) { + const client = makeQueryClient(); + const router = createMemoryRouter([{ path, element: ui }, { path: "*", element:

другая страница

}], + { initialEntries: [route] }); + return { client, router, ...render() }; +} +``` + +- [ ] **Step 3: Падающие тесты токена и клиента** + +`web/src/auth/token.test.ts`: + +```ts +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +describe("token", () => { + beforeEach(() => { vi.resetModules(); localStorage.clear(); }); + afterEach(() => { vi.restoreAllMocks(); }); + + it("читает токен, сохранённый прежней доской", async () => { + localStorage.setItem("jira-hub-token", "abc"); + const t = await import("./token.ts"); + expect(t.getToken()).toBe("abc"); + }); + + it("setToken и clearToken пишут в localStorage и оповещают подписчиков", async () => { + const t = await import("./token.ts"); + const fn = vi.fn(); + t.subscribe(fn); + t.setToken("xyz"); + expect(localStorage.getItem("jira-hub-token")).toBe("xyz"); + t.clearToken("bad"); + expect(t.getAuth()).toEqual({ token: "", reason: "bad" }); + expect(localStorage.getItem("jira-hub-token")).toBeNull(); + expect(fn).toHaveBeenCalledTimes(2); + }); + + it("закрытое хранилище: токен живёт в памяти, исключений нет", async () => { + vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => { throw new DOMException("closed", "SecurityError"); }); + vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { throw new DOMException("closed", "SecurityError"); }); + const t = await import("./token.ts"); + expect(t.getToken()).toBe(""); + t.setToken("mem"); + expect(t.getToken()).toBe("mem"); + }); + + it("TOKEN_RE пропускает только печатный ASCII без пробелов", async () => { + const { TOKEN_RE } = await import("./token.ts"); + expect(TOKEN_RE.test("Ab-_9~")).toBe(true); + expect(TOKEN_RE.test("ab c")).toBe(false); + expect(TOKEN_RE.test("токен")).toBe(false); + }); +}); +``` + +`web/src/api/client.test.ts`: + +```ts +import { beforeEach, describe, expect, it } from "vitest"; +import { clearToken, setToken } from "../auth/token.ts"; +import { json, mockFetch } from "../test/fetchMock.ts"; +import { api, ApiError, AuthError, checkToken, HubDownError } from "./client.ts"; + +describe("api", () => { + beforeEach(() => setToken("tok")); + + it("шлёт токен и параметры, пустые параметры выбрасывает", async () => { + const fetch = mockFetch(() => json(200, { issues: [] })); + await api.get("/issues", { sprint: "S 2", q: "", epic: null, limit: 50 }); + const [url, init] = fetch.mock.calls[0]; + expect(url).toBe("/issues?sprint=S+2&limit=50"); + expect((init!.headers as Record).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)["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).Authorization).toBe("Bearer candidate"); + expect(fetch.mock.calls[0][0]).toBe("/settings"); + }); +}); + +``` + +Run: `cd web && npx vitest run src/api src/auth` +Expected: FAIL — модулей `client.ts`, `token.ts` нет. + +- [ ] **Step 4: `token.ts`, `useAuth.ts`, `client.ts`, `queryClient.ts`** + +`web/src/auth/token.ts`: + +```ts +// клиентский токен хаба: localStorage["jira-hub-token"] (тот же ключ, что у прежней доски) и копия в памяти на случай +// закрытого хранилища. reason — почему показан экран входа: "bad" — токен не подошёл (ответ 401) +export const TOKEN_KEY = "jira-hub-token"; +export const TOKEN_RE = /^[\x21-\x7e]+$/; // печатный ASCII без пробелов: иначе fetch бросит TypeError на заголовке + +export type LoginReason = "none" | "bad"; +export interface AuthSnapshot { token: string; reason: LoginReason } + +const readStored = (): string => { + try { return localStorage.getItem(TOKEN_KEY) ?? ""; } catch { return ""; } +}; + +let state: AuthSnapshot = { token: readStored(), reason: "none" }; +const listeners = new Set<() => void>(); +const emit = (next: AuthSnapshot) => { state = next; listeners.forEach((l) => l()); }; + +export const getAuth = (): AuthSnapshot => state; +export const getToken = (): string => state.token; + +export function setToken(token: string): void { + try { localStorage.setItem(TOKEN_KEY, token); } catch { /* закрытое хранилище: токен только в памяти */ } + emit({ token, reason: "none" }); +} + +export function clearToken(reason: LoginReason = "none"): void { + try { localStorage.removeItem(TOKEN_KEY); } catch { /* закрытое хранилище */ } + emit({ token: "", reason }); +} + +export function subscribe(fn: () => void): () => void { + listeners.add(fn); + return () => { listeners.delete(fn); }; +} +``` + +`web/src/auth/useAuth.ts`: + +```ts +import { useSyncExternalStore } from "react"; +import { getAuth, subscribe, type AuthSnapshot } from "./token.ts"; + +export const useAuth = (): AuthSnapshot => useSyncExternalStore(subscribe, getAuth); +``` + +`web/src/api/client.ts`: + +```ts +// единственный клиент HTTP API хаба: тот же хост, клиентский токен в Authorization, ответы — JSON. +// Ошибки: AuthError — 401 или нет токена; HubDownError — сеть или 5xx; ApiError — прочие 4xx с {error, detail} +import { getToken } from "../auth/token.ts"; +import type { HubSettings } from "./types.ts"; + +export class AuthError extends Error {} +export class HubDownError extends Error {} +export class ApiError extends Error { + readonly status: number; + readonly error: string; + readonly detail: string | null; + constructor(status: number, error: string, detail: string | null) { + super(detail ? `${error}: ${detail}` : error); + this.status = status; + this.error = error; + this.detail = detail; + } +} + +export type Params = Record; + +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(method: string, path: string, body?: unknown, token = getToken()): Promise { + if (!token) throw new AuthError("нет токена"); + const headers: Record = { 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: (path: string, params?: Params) => call("GET", withParams(path, params)), + post: (path: string, body: unknown = {}) => call("POST", path, body), + put: (path: string, body: unknown) => call("PUT", path, body), +}; + +// проверка токена до сохранения: экран входа +export const checkToken = (token: string) => call("GET", "/settings", undefined, token); +``` + +`web/src/api/queryClient.ts`: + +```ts +import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query"; +import { clearToken, getToken } from "../auth/token.ts"; +import { AuthError } from "./client.ts"; + +// 401 в любом запросе или действии — экран входа с пояснением «токен не подошёл» +export function makeQueryClient(): QueryClient { + const onError = (e: unknown) => { if (e instanceof AuthError) clearToken(getToken() ? "bad" : "none"); }; + return new QueryClient({ + queryCache: new QueryCache({ onError }), + mutationCache: new MutationCache({ onError }), + defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false, staleTime: 30_000 } }, + }); +} +``` + +- [ ] **Step 5: Тест экрана входа** + +`web/src/auth/LoginScreen.test.tsx`: + +```tsx +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it } from "vitest"; +import { json, mockFetch } from "../test/fetchMock.ts"; +import { renderWithProviders } from "../test/render.tsx"; +import { AuthGate } from "./AuthGate.tsx"; +import { clearToken, getAuth } from "./token.ts"; + +const app =

страница приложения

; + +describe("вход", () => { + beforeEach(() => clearToken()); + + it("без токена — экран входа, страница не видна", () => { + renderWithProviders(app); + expect(screen.getByRole("heading", { name: "Вход в хаб" })).toBeInTheDocument(); + expect(screen.queryByText("страница приложения")).not.toBeInTheDocument(); + expect(screen.getByText(/uv run jira-hub tokens/)).toBeInTheDocument(); + }); + + it("верный токен сохраняется и открывает приложение", async () => { + const fetch = mockFetch(() => json(200, {})); + renderWithProviders(app); + await userEvent.type(screen.getByLabelText("Клиентский токен хаба"), "good-token"); + await userEvent.click(screen.getByRole("button", { name: "Войти" })); + expect(await screen.findByText("страница приложения")).toBeInTheDocument(); + expect(localStorage.getItem("jira-hub-token")).toBe("good-token"); + expect(fetch.mock.calls[0][0]).toBe("/settings"); + }); + + it("неверный токен не сохраняется, поле не стирается", async () => { + mockFetch(() => json(401, { error: "unauthorized" })); + renderWithProviders(app); + const input = screen.getByLabelText("Клиентский токен хаба"); + await userEvent.type(input, "wrong"); + await userEvent.click(screen.getByRole("button", { name: "Войти" })); + expect(await screen.findByText(/Токен не подошёл/)).toBeInTheDocument(); + expect(input).toHaveValue("wrong"); + expect(localStorage.getItem("jira-hub-token")).toBeNull(); + }); + + it("кириллица или пробел — «недопустимые символы», запроса нет", async () => { + const fetch = mockFetch(() => json(200, {})); + renderWithProviders(app); + await userEvent.type(screen.getByLabelText("Клиентский токен хаба"), "ток ен"); + await userEvent.click(screen.getByRole("button", { name: "Войти" })); + expect(screen.getByText(/недопустимые символы/)).toBeInTheDocument(); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("хаб не отвечает — своё сообщение, не «токен не подошёл»", async () => { + mockFetch(() => { throw new TypeError("Failed to fetch"); }); + renderWithProviders(app); + await userEvent.type(screen.getByLabelText("Клиентский токен хаба"), "tok"); + await userEvent.click(screen.getByRole("button", { name: "Войти" })); + expect(await screen.findByText(/Хаб не отвечает/)).toBeInTheDocument(); + }); + + it("после 401 в приложении экран входа поясняет причину", async () => { + clearToken("bad"); + renderWithProviders(app); + await waitFor(() => expect(getAuth().reason).toBe("bad")); + expect(screen.getByText(/Токен не подошёл/)).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 6: `LoginScreen.tsx` и `AuthGate.tsx`** + +`web/src/auth/LoginScreen.tsx` (разметка и тексты — как у `links.html`, строки 287–293 и 1054, 1080; строки про +загрузку файла без хаба нет — R-9): + +```tsx +import { useQueryClient } from "@tanstack/react-query"; +import { useState, type FormEvent } from "react"; +import { AuthError, checkToken } from "../api/client.ts"; +import { setToken, TOKEN_RE } from "./token.ts"; +import { useAuth } from "./useAuth.ts"; + +const BAD_TOKEN = "Токен не подошёл. Проверьте его по uv run jira-hub tokens: токен могли пересоздать."; +const BAD_CHARS = "В токене недопустимые символы: скопируйте строку после «клиентский токен:» целиком, без пробелов и кириллицы."; +const HUB_DOWN = "Хаб не отвечает: проверьте, что jira-hub запущен, и попробуйте снова."; + +export function LoginScreen() { + const { reason } = useAuth(); + const qc = useQueryClient(); + const [value, setValue] = useState(""); + const [error, setError] = useState(reason === "bad" ? BAD_TOKEN : ""); + const [busy, setBusy] = useState(false); + + async function submit(ev: FormEvent) { + ev.preventDefault(); + const t = value.trim(); + if (!t) return; + if (!TOKEN_RE.test(t)) { setError(BAD_CHARS); return; } + setBusy(true); + try { + await checkToken(t); + qc.clear(); + setToken(t); + } catch (e) { + setError(e instanceof AuthError ? BAD_TOKEN : HUB_DOWN); + } finally { + setBusy(false); + } + } + + return ( +
+

Вход в хаб

+

Приложению нужен клиентский токен хаба. Его показывает команда uv run jira-hub tokens в каталоге + jira-hub на этом Mac. Токен сохранится в этом браузере.

+ {error &&

{error}

} +
+ setValue(e.target.value)} /> + +
+
+ ); +} +``` + +`web/src/auth/AuthGate.tsx`: + +```tsx +import type { ReactNode } from "react"; +import { LoginScreen } from "./LoginScreen.tsx"; +import { useAuth } from "./useAuth.ts"; + +// пока нет токена — только экран входа: страниц и их фоновых запросов нет (M-6) +export function AuthGate({ children }: { children: ReactNode }) { + const { token } = useAuth(); + return token ? <>{children} : ; +} +``` + +- [ ] **Step 7: Хуки запросов** — `web/src/api/queries.ts`: + +```ts +import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { api } from "./client.ts"; +import type { BoardSnapshot, HubSettings, IssueDetail, IssueRow, Note, NoteFields } from "./types.ts"; + +export const ISSUE_FILTER_NAMES = ["sprint", "assignee", "work_kind", "fact_status", "status", "label", "epic", "neighbor", "q"] as const; +export type IssueFilters = Partial>; +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("/settings"), staleTime: Infinity }); + +export const useBoard = () => useQuery({ queryKey: qk.board, queryFn: () => api.get("/board") }); + +export const useIssue = (key: string) => + useQuery({ queryKey: qk.issue(key), queryFn: () => api.get(`/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(`/issues/${encodeURIComponent(key)}/note`, { ...fields, updated_by: "web" }), + onSuccess: (note) => { + qc.setQueryData(qk.issue(key), (d) => (d ? { ...d, note } : d)); + void qc.invalidateQueries({ queryKey: ["issues"] }); + void qc.invalidateQueries({ queryKey: qk.board }); + }, + }); +} +``` + +- [ ] **Step 8: Прогнать** + +Run: `cd web && npm run lint && npm test` +Expected: PASS — `token.test.ts` 4, `client.test.ts` 5, `LoginScreen.test.tsx` 6, прежние 3. + +- [ ] **Step 9: Commit** + +```bash +git add web/src/api web/src/auth web/src/test +git commit -m "feat(web): клиент API, токен и экран входа, хуки запросов (#21) + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 4: оболочка — маршруты под `/app`, панель разделов, ошибки, тема, базовые стили + +**Модель:** haiku (код готов; стили — перенос по строкам). + +**Files:** +- Create: `web/src/shell/Layout.tsx`, `web/src/shell/ErrorBoundary.tsx`, `web/src/shell/NotFound.tsx`, + `web/src/shell/theme.ts`, `web/src/shell/shell.css`, `web/src/styles/theme.css`, `web/src/styles/base.css`, + `web/src/styles/controls.css`, `web/src/routes.tsx`, временные `web/src/board/BoardPage.tsx`, + `web/src/issues/IssuesPage.tsx`, `web/src/issues/IssuePage.tsx` (заменяются в Task 11, 13, 14) +- Modify: `web/src/App.tsx`, `web/src/main.tsx`, `web/src/App.test.tsx` +- Test: `web/src/App.test.tsx`, `web/src/shell/theme.test.ts` + +**Interfaces:** +- Consumes: `AuthGate`, `makeQueryClient`, `clearToken` (Task 3). +- Produces: `routes: RouteObject[]` (`routes.tsx`); `App({router?})`; `theme.ts`: `THEMES = ["", "light", "dark"]`, + `THEME_NAMES`, `type Theme`, `readTheme(): Theme`, `applyTheme(t: Theme): void`, `saveTheme(t: Theme): void` — ключ + `graph-theme`, значение `""|"light"|"dark"` (как у `links.html`, строки 1000–1003). + Страницы: `BoardPage` (`web/src/board/BoardPage.tsx`), `IssuesPage`, `IssuePage` — экспорт по имени. + +- [ ] **Step 1: Стили из `links.html`** + +Перенести CSS ревизии 3931a88 как есть (кроме перечисленных правок): +- `web/src/styles/theme.css` ← строки 12–30 (раздел 1, цвета и темы); +- `web/src/styles/base.css` ← строки 32–40 (раздел 2), но в правиле `body` убрать `padding-inline` и `padding-block` + (отступы даёт `.page`); добавить в конец: + `.page{padding-inline:16px;padding-block:0 32px}` и + `.login{max-width:560px;margin:48px auto}.login form{display:flex;gap:8px}.login input{flex:1;font:inherit;height:34px;padding:0 10px;border:1px solid var(--line);border-radius:6px;background:var(--card);color:var(--fg)}.login .err{color:var(--p-crit)}`; +- `web/src/styles/controls.css` ← строки 53–91 (раздел 4, кнопки и выпадающие списки) и из раздела 3 правила `.link`, + `.link:hover,.link:focus-visible`, `.link:disabled` (строки 47–49). + +`web/src/shell/shell.css`: + +```css +/* оболочка: верхняя панель разделов */ +.appbar{display:flex;align-items:center;gap:16px;padding:10px 16px;border-bottom:1px solid var(--line);background:var(--panel)} +.appbar .brand{font-weight:600} +.appbar nav{display:flex;gap:4px} +.appbar nav a{color:var(--fg);text-decoration:none;padding:6px 10px;border-radius:6px} +.appbar nav a.active{background:var(--accent);color:var(--accent-fg)} +.appbar .spacer{flex:1 1 auto} +.crash{max-width:640px;margin:48px auto} +``` + +- [ ] **Step 2: Тема** — тест `web/src/shell/theme.test.ts`: + +```ts +import { describe, expect, it } from "vitest"; +import { applyTheme, readTheme, saveTheme } from "./theme.ts"; + +describe("тема", () => { + it("читает graph-theme, неизвестное значение — системная", () => { + localStorage.setItem("graph-theme", "dark"); + expect(readTheme()).toBe("dark"); + localStorage.setItem("graph-theme", "purple"); + expect(readTheme()).toBe(""); + }); + it("applyTheme ставит и снимает data-theme, saveTheme пишет ключ", () => { + applyTheme("light"); + expect(document.documentElement.getAttribute("data-theme")).toBe("light"); + applyTheme(""); + expect(document.documentElement.hasAttribute("data-theme")).toBe(false); + saveTheme("dark"); + expect(localStorage.getItem("graph-theme")).toBe("dark"); + }); +}); +``` + +`web/src/shell/theme.ts`: + +```ts +// тема интерфейса: "" — системная, light, dark; ключ graph-theme, как у прежней доски (links.html, строки 1000–1003) +export const THEMES = ["", "light", "dark"] as const; +export type Theme = (typeof THEMES)[number]; +export const THEME_NAMES: Record = { "": "системная", light: "светлая", dark: "тёмная" }; +const KEY = "graph-theme"; + +export function readTheme(): Theme { + let v: string | null = null; + try { v = localStorage.getItem(KEY); } catch { /* закрытое хранилище */ } + return (THEMES as readonly string[]).includes(v ?? "") ? (v as Theme) : ""; +} + +export function saveTheme(t: Theme): void { + try { localStorage.setItem(KEY, t); } catch { /* закрытое хранилище */ } +} + +export function applyTheme(t: Theme): void { + if (t) document.documentElement.setAttribute("data-theme", t); + else document.documentElement.removeAttribute("data-theme"); +} +``` + +- [ ] **Step 3: Тест оболочки** — заменить `web/src/App.test.tsx`: + +```tsx +import { screen } from "@testing-library/react"; +import { render } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { createMemoryRouter } from "react-router"; +import { beforeEach, describe, expect, it } from "vitest"; +import { App } from "./App.tsx"; +import { clearToken, getAuth, setToken } from "./auth/token.ts"; +import { routes } from "./routes.tsx"; +import { json, mockFetch } from "./test/fetchMock.ts"; + +const at = (path: string) => createMemoryRouter(routes, { initialEntries: [path], basename: "/app" }); + +describe("оболочка", () => { + beforeEach(() => { setToken("tok"); mockFetch(() => json(200, {})); }); + + it("/app ведёт на доску", async () => { + render(); + expect(await screen.findByTestId("page-board")).toBeInTheDocument(); + }); + + it("переход «Задачи» и обратно", async () => { + const router = at("/app/board"); + render(); + 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(); + expect(await screen.findByRole("heading", { name: "Страница не найдена" })).toBeInTheDocument(); + }); + + it("«Сменить токен» показывает экран входа", async () => { + render(); + 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: , children: [{ path: "x", element: }] }], + { initialEntries: ["/x"] }); + render(); + expect(await screen.findByText(/сломалось/)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Перезагрузить" })).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "Доска" })).toBeInTheDocument(); + }); + + it("без токена — только экран входа", () => { + clearToken(); + render(); + expect(screen.queryByRole("link", { name: "Доска" })).not.toBeInTheDocument(); + }); +}); +``` + +Исключение в `Boom` React дополнительно печатает в консоль — это ожидаемо; при желании заглушить +`vi.spyOn(console, "error").mockImplementation(() => {})` в этом тесте. + +- [ ] **Step 4: Реализация** + +`web/src/shell/ErrorBoundary.tsx`: + +```tsx +import { Component, type ErrorInfo, type ReactNode } from "react"; + +interface State { error: Error | null } + +// неожиданная ошибка отрисовки страницы: сообщение и перезагрузка вместо пустого экрана; панель разделов остаётся +export class ErrorBoundary extends Component<{ children: ReactNode }, State> { + state: State = { error: null }; + static getDerivedStateFromError(error: Error): State { return { error }; } + componentDidCatch(error: Error, info: ErrorInfo) { console.error("ошибка страницы", error, info.componentStack); } + render() { + if (!this.state.error) return this.props.children; + return ( +
+

Страница сломалась

+

{this.state.error.message}

+ +
+ ); + } +} +``` + +`web/src/shell/NotFound.tsx`: + +```tsx +import { Link } from "react-router"; + +export function NotFound() { + return ( +
+

Страница не найдена

+

Разделы приложения: доска, задачи.

+
+ ); +} +``` + +`web/src/shell/Layout.tsx`: + +```tsx +import { useQueryClient } from "@tanstack/react-query"; +import { NavLink, Outlet, useLocation } from "react-router"; +import { clearToken } from "../auth/token.ts"; +import { ErrorBoundary } from "./ErrorBoundary.tsx"; +import "./shell.css"; + +export function Layout() { + const qc = useQueryClient(); + const { pathname } = useLocation(); + return ( + <> +
+ Хаб Jira + + + +
+
+ +
+ + ); +} +``` + +Временные страницы (заменяются в Task 11, 13, 14): + +```tsx +// web/src/board/BoardPage.tsx +export function BoardPage() { return
; } +// web/src/issues/IssuesPage.tsx +export function IssuesPage() { return
; } +// web/src/issues/IssuePage.tsx +export function IssuePage() { return
; } +``` + +`web/src/routes.tsx`: + +```tsx +import { Navigate, type RouteObject } from "react-router"; +import { BoardPage } from "./board/BoardPage.tsx"; +import { IssuePage } from "./issues/IssuePage.tsx"; +import { IssuesPage } from "./issues/IssuesPage.tsx"; +import { Layout } from "./shell/Layout.tsx"; +import { NotFound } from "./shell/NotFound.tsx"; + +// адреса относительно basename "/app": /app → /app/board +export const routes: RouteObject[] = [{ + path: "/", + element: , + children: [ + { index: true, element: }, + { path: "board", element: }, + { path: "issues", element: }, + { path: "issues/:key", element: }, + { path: "*", element: }, + ], +}]; +``` + +`web/src/App.tsx`: + +```tsx +import { QueryClientProvider } from "@tanstack/react-query"; +import { useState } from "react"; +import { createBrowserRouter, RouterProvider } from "react-router"; +import { makeQueryClient } from "./api/queryClient.ts"; +import { AuthGate } from "./auth/AuthGate.tsx"; +import { routes } from "./routes.tsx"; +import { Layout } from "./shell/Layout.tsx"; +import "./styles/theme.css"; +import "./styles/base.css"; +import "./styles/controls.css"; + +type Router = ReturnType; + +export function App({ router }: { router?: Router }) { + const [client] = useState(makeQueryClient); + const [r] = useState(() => router ?? createBrowserRouter(routes, { basename: "/app" })); + return ( + + + + ); +} +App.Layout = Layout; +``` + +`web/src/main.tsx` — перед `createRoot` добавить применение сохранённой темы: + +```tsx +import { applyTheme, readTheme } from "./shell/theme.ts"; +applyTheme(readTheme()); +``` + +- [ ] **Step 5: Прогнать и собрать** + +Run: `cd web && npm run lint && npm test && npm run build` +Expected: PASS (оболочка 6, тема 2, прежние); сборка в `src/jira_hub/web/app/`. + +- [ ] **Step 6: Commit** + +```bash +git add web/src +git commit -m "feat(web): оболочка приложения — маршруты /app, разделы, ошибки страниц, тема (#21) + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- +### 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: {: {visible, + emptyReason, layout: {rel, laneOf, colBounds, baseW, pos, lanes, board: {width, height}, clamp, dateCols, dateLabels, + edges}}}, export}`. Фиксированная дата всех вычислений — `2026-10-14T12:00:00.000Z` (`today = "2026-10-14"`). + Высоты карточек в заглушке DOM: карточка — 117 px (`measuredHeight` → 120), заголовок карточки — 38 px (`other` = 79). + +- [ ] **Step 1: Фикстуры данных** + +`web/src/board/__fixtures__/settings.json` — словари из `admin/jira-sync/config.example.json` (как в настройках живого хаба): + +```json +{ + "kind_labels": {"аналитика": "kind-analytics", "решение": "kind-decision", "актуализация": "kind-docs", "разработка": "kind-dev", "UI": "kind-ui", "тестирование": "kind-test", "организационная": "kind-org"}, + "link_types": {"блокирует начало": "ASUPR_Блокер_Начала", "блокирует завершение": "ASUPR_Блокер_Завершения", "связанные": "ASUPR_Связанные"}, + "status_labels": {"Готово — закрыть": "fact-done", "В работе": "fact-in-progress", "Ожидание разработки": "fact-open"} +} +``` + +`web/src/board/__fixtures__/hub-basic.board.json` (что покрывает: эпик из снимка и эпик из запасного словаря `EPICS` +(AURORA-1359 «Гудвилл»), неизвестный эпик, задача без эпика, сосед, заглушка «Нет в зеркале», три типа связей, дубль +«связанных» в обратную сторону, связь с эпиком, неизвестный тип, ключ не вида AURORA-1234, совпадающие сроки, блокер позже +зависимой, блокер без исполнителя, не начатый блокер у близкого срока, передачи, цепочка для критического пути, четыре +спринта и «без спринта», логин вместо ФИО): + +```json +{ + "generated_at": "2026-10-14T10:00:00+00:00", + "last_sync": {"started_at": "2026-10-14T09:00:00+00:00", "finished_at": "2026-10-14T09:01:00+00:00", "issues_count": 10}, + "issues": [ + {"key": "AURORA-9100", "summary": "Тестовый эпик", "issue_type": "Фича", "status": "В работе", "priority": "Средний", "assignee": null, "sprint": null, "duedate": null, "planned_start": null, "planned_end": null, "epic_key": null, "labels": ["AWPCA"], "neighbor": false, "fact_status": null, "work_kind": null}, + {"key": "AURORA-9001", "summary": "Схема свода", "issue_type": "Задача", "status": "В работе", "priority": "Высокий", "assignee": "Иванов Иван Иванович", "sprint": "AWPCA_Спринт 2", "duedate": "2026-10-30", "planned_start": null, "planned_end": "2026-10-20", "epic_key": "AURORA-9100", "labels": ["AWPCA", "kind-dev"], "neighbor": false, "fact_status": "В работе", "work_kind": "разработка"}, + {"key": "AURORA-9002", "summary": "API свода", "issue_type": "Задача", "status": "Ожидание разработки", "priority": "Средний", "assignee": "Петров Пётр Петрович", "sprint": "AWPCA_Спринт 2", "duedate": "2026-10-20", "planned_start": null, "planned_end": null, "epic_key": "AURORA-9100", "labels": ["kind-dev"], "neighbor": false, "fact_status": null, "work_kind": null}, + {"key": "AURORA-9003", "summary": "Макет экрана", "issue_type": "Задача", "status": "Backlog", "priority": "Критичный", "assignee": null, "sprint": "AWPCA_Спринт 2", "duedate": null, "planned_start": null, "planned_end": "2026-10-16", "epic_key": "AURORA-9100", "labels": ["kind-ui"], "neighbor": false, "fact_status": null, "work_kind": null}, + {"key": "AURORA-9004", "summary": "Решение по порогам", "issue_type": "Задача", "status": "К выполнению", "priority": "Наивысший", "assignee": "Сидорова Анна", "sprint": "AWPCA_Спринт 3", "duedate": null, "planned_start": null, "planned_end": "2026-10-25", "epic_key": "AURORA-1359", "labels": ["kind-decision"], "neighbor": false, "fact_status": null, "work_kind": null}, + {"key": "AURORA-9005", "summary": "Тест-кейсы", "issue_type": "Задача", "status": "Ожидание", "priority": "Низкий", "assignee": "Петров Пётр Петрович", "sprint": "AWPCA_Спринт 3", "duedate": null, "planned_start": null, "planned_end": null, "epic_key": "AURORA-1359", "labels": ["kind-test"], "neighbor": false, "fact_status": null, "work_kind": null}, + {"key": "AURORA-9006", "summary": "Задача без эпика", "issue_type": "Задача", "status": "Готово", "priority": "Средний", "assignee": "Иванов Иван Иванович", "sprint": null, "duedate": "2026-10-10", "planned_start": null, "planned_end": null, "epic_key": null, "labels": [], "neighbor": false, "fact_status": null, "work_kind": null}, + {"key": "AURORA-9007", "summary": "Сосед по связям", "issue_type": "Задача", "status": "В работе", "priority": "Средний", "assignee": "Kozlov-AA", "sprint": "AWPCA_Спринт 1", "duedate": null, "planned_start": null, "planned_end": "2026-10-12", "epic_key": "AURORA-9100", "labels": ["kind-analytics"], "neighbor": true, "fact_status": null, "work_kind": null}, + {"key": "AURORA-9008", "summary": "Актуализация ТЗ", "issue_type": "Задача", "status": "Ожидание разработки", "priority": "Самый низкий", "assignee": "Иванов Иван Иванович", "sprint": "AWPCA_Спринт 2", "duedate": null, "planned_start": null, "planned_end": "2026-10-18", "epic_key": "AURORA-9999", "labels": ["kind-docs"], "neighbor": false, "fact_status": null, "work_kind": null}, + {"key": "AURORA-9009", "summary": "Созвон с заказчиком", "issue_type": "Задача", "status": "", "priority": "Средний", "assignee": "Иванов Иван Иванович", "sprint": "AWPCA_Спринт 2", "duedate": null, "planned_start": null, "planned_end": "2026-10-20", "epic_key": "AURORA-9100", "labels": ["kind-org"], "neighbor": false, "fact_status": null, "work_kind": null} + ], + "links": [ + {"source": "AURORA-9001", "target": "AURORA-9002", "link_type": "ASUPR_Блокер_Начала"}, + {"source": "AURORA-9003", "target": "AURORA-9001", "link_type": "ASUPR_Блокер_Начала"}, + {"source": "AURORA-9002", "target": "AURORA-9005", "link_type": "ASUPR_Блокер_Завершения"}, + {"source": "AURORA-9004", "target": "AURORA-9002", "link_type": "ASUPR_Блокер_Начала"}, + {"source": "AURORA-9001", "target": "AURORA-9008", "link_type": "ASUPR_Связанные"}, + {"source": "AURORA-9008", "target": "AURORA-9001", "link_type": "ASUPR_Связанные"}, + {"source": "AURORA-9006", "target": "AURORA-9500", "link_type": "ASUPR_Блокер_Начала"}, + {"source": "AURORA-9001", "target": "AURORA-9100", "link_type": "ASUPR_Блокер_Начала"}, + {"source": "AURORA-9009", "target": "AURORA-9002", "link_type": "Duplicate"}, + {"source": "AURORA-9001", "target": "lower-1", "link_type": "ASUPR_Связанные"}, + {"source": "AURORA-9007", "target": "AURORA-9009", "link_type": "ASUPR_Блокер_Начала"} + ] +} +``` + +`web/src/board/__fixtures__/hub-empty.board.json`: + +```json +{"generated_at": "2026-10-14T10:00:00+00:00", "last_sync": null, "issues": [], "links": []} +``` + +`web/src/board/__fixtures__/scenarios.json` — наборы фильтров и сохранённого состояния доски; отсутствующие поля — +значения по умолчанию (флаги `false`, множества пустые, хранилище пустое): + +```json +[ + {"name": "default", "filters": {}}, + {"name": "sprint-2", "filters": {"sprints": ["AWPCA_Спринт 2"]}}, + {"name": "sprint-2-neighbors", "filters": {"sprints": ["AWPCA_Спринт 2"], "neighbors": true}}, + {"name": "who-ivanov", "filters": {"who": ["Иванов Иван Иванович"], "neighbors": true}}, + {"name": "hand-linked", "filters": {"onlyHand": true, "onlyLinked": true}}, + {"name": "crit", "filters": {"crit": true}}, + {"name": "hide", "filters": {"hideKind": ["dev"], "hideEdge": ["rel"]}}, + {"name": "empty", "filters": {"who": ["Никто Никтович"]}}, + {"name": "lane-order", "filters": {}, "laneOrder": ["Гудвилл", "Без эпика", "Тестовый эпик"]}, + {"name": "by-date", "filters": {"byDate": true}}, + {"name": "saved-folded", "filters": {}, "saved": {"AURORA-9002": {"x": 600, "ry": 80, "w": 280, "h": 160}, "AURORA-9003": {"x": 30, "ry": 50}}, "folded": ["Гудвилл"]}, + {"name": "by-date-colw", "filters": {"byDate": true}, "colWidths": {"2026-10-20": 400}, "savedDate": {"AURORA-9001": {"x": 10, "ry": 60, "w": 250}}} +] +``` + +CSV-фикстуры создать командой (BOM и CRLF у `jira-export.csv` — часть проверки разбора): + +```bash +python3 - <<'EOF' +from pathlib import Path +d = Path("web/src/board/__fixtures__") +rows = [ + "Ключ проблемы,Тема,Статус,Приоритет,Тип задачи,Метки,Метки,Связи Фичи,Плановая дата окончания,Исполнитель,Спринт,Спринт," + "Внешняя ссылка на проблему (ASUPR_Блокер_Начала),Внутренняя ссылка на проблему (ASUPR_Блокер_Начала)," + "Внешняя ссылка на проблему (ASUPR_Связанные)", + "AURORA-9100,Тестовый эпик,В работе,Средний,Фича,AWPCA,,,,,,,,,", + 'AURORA-9001,"Схема свода, черновик",В работе,Высокий,Задача,AWPCA,kind-dev,AURORA-9100,20.10.2026,Иванов Иван Иванович,' + "AWPCA_Спринт 1,AWPCA_Спринт 2,AURORA-9002,AURORA-9003,AURORA-9008", + 'AURORA-9002,"API ""свода""",Ожидание разработки,Средний,Задача,kind-dev,,AURORA-9100,20/окт/26,Петров Пётр Петрович,' + "AWPCA_Спринт 2,,,,", + "AURORA-9003,Макет экрана,Backlog,Критичный,Задача,kind-ui,,AURORA-1359,2026-10-16,,AWPCA_Спринт 2,,AURORA-9001,,", + "AURORA-9006,Задача без эпика,Готово,Средний,Задача,,,,10/Oct/26 12:00 PM,Иванов Иван Иванович,,,AURORA-9500,,", + "not-a-key,мусор,,,,,,,,,,,,,", + "", +] +(d / "jira-export.csv").write_bytes(("" + "\r\n".join(rows)).encode("utf-8")) +(d / "jira-no-links.csv").write_text("Issue key,Summary,Status\nAURORA-9001,Тема,Open\n", encoding="utf-8") +(d / "jira-bad.csv").write_text("Номер,Название\n1,x\n", encoding="utf-8") +EOF +``` + +`web/src/board/__fixtures__/old-board.json` — выгрузка старой страницы (старый `mode`, битая раскладка, дубль ключа, +заглушка, неизвестный тип связи, связь на ключ вне файла): + +```json +{"format": "arm-ka-links/1", "exportedAt": "2026-10-11T08:00:00.000Z", "source": "sprint2.csv", + "filters": {"mode": "linked", "sprints": ["AWPCA_Спринт 2"], "assignees": ["Петров Пётр Петрович"], "onlyHand": true, "criticalPath": false, "byDate": true, "hiddenKinds": ["org"], "hiddenEdges": []}, + "laneOrder": ["Гудвилл", "Тестовый эпик"], "collapsedLanes": ["Без эпика"], "dateColumns": {"2026-10-20": 360}, + "nodes": [ + {"key": "AURORA-9001", "title": "Схема свода", "status": "В работе", "priority": "Высокий", "kind": "разработка", "due": "2026-10-20", "assignee": "Иванов Иван Иванович", "sprint": "AWPCA_Спринт 2", "epic": "Тестовый эпик", "layout": {"x": 24, "ry": 40, "w": 240}, "layoutByDate": {"x": 6, "ry": 40, "w": 240, "h": 144}}, + {"key": "AURORA-9002", "title": "API свода", "status": "Ожидание разработки", "priority": "Средний", "kind": "разработка", "due": "20.10.2026", "assignee": "Петров Пётр Петрович", "sprint": "AWPCA_Спринт 2", "epic": "Тестовый эпик", "layout": {"x": "плохо", "ry": 40}}, + {"key": "AURORA-9001", "title": "дубль", "epic": "x"}, + {"key": "AURORA-9500", "stub": true}, + {"key": "bad key"} + ], + "links": [{"from": "AURORA-9001", "to": "AURORA-9002", "type": "блокирует начало"}, {"from": "AURORA-9002", "to": "AURORA-9777", "type": "неизвестный"}, {"from": "x", "to": "AURORA-9001", "type": "связанные"}]} +``` + +`web/src/board/__fixtures__/geometry.cases.json`: + +```json +{"edge": [ + {"a": {"x": 24, "y": 40, "w": 240, "h": 120}, "b": {"x": 320, "y": 40, "w": 240, "h": 120}}, + {"a": {"x": 24, "y": 40, "w": 240, "h": 120}, "b": {"x": 320, "y": 200, "w": 240, "h": 96}}, + {"a": {"x": 620, "y": 40, "w": 240, "h": 120}, "b": {"x": 24, "y": 300, "w": 240, "h": 120}}, + {"a": {"x": 24, "y": 40, "w": 240, "h": 120}, "b": {"x": 100, "y": 100, "w": 240, "h": 120}}, + {"a": {"x": 24, "y": 40, "w": 240, "h": 120}, "b": {"x": 30, "y": 400, "w": 240, "h": 120}}, + {"a": {"x": 24, "y": 400, "w": 240, "h": 120}, "b": {"x": 24, "y": 40, "w": 240, "h": 120}}], + "ortho": [[{"x": 0, "y": 0}, {"x": 10, "y": 0}], [{"x": 0, "y": 0}, {"x": 1, "y": 0}, {"x": 1, "y": 30}, {"x": 40, "y": 30}]], + "align": [ + {"pos": {"A": {"x": 24, "y": 40, "w": 240, "h": 120}, "B": {"x": 320, "y": 200, "w": 240, "h": 96}}, "key": "B", "r": {"x": 28, "y": 203, "w": 240, "h": 96}, "resizing": false}, + {"pos": {"A": {"x": 24, "y": 40, "w": 240, "h": 120}, "B": {"x": 320, "y": 200, "w": 240, "h": 96}}, "key": "B", "r": {"x": 320, "y": 200, "w": 262, "h": 98}, "resizing": true}, + {"pos": {"A": {"x": 24, "y": 40, "w": 240, "h": 120}}, "key": "B", "r": {"x": 500, "y": 500, "w": 240, "h": 96}, "resizing": false}]} +``` + +`web/src/board/__fixtures__/storage.cases.json` (`op: "migrate"` — только `migrateStore()`; `"startup"` — код строки 1203 +и затем `migrateStore()`): + +```json +[ + {"name": "migrate-largest", "op": "migrate", "before": {"graph-pos:old.csv": "{\"AURORA-9001\":{\"x\":1,\"ry\":40}}", "graph-pos:new.csv": "{\"AURORA-9001\":{\"x\":1,\"ry\":40},\"AURORA-9002\":{\"x\":300,\"ry\":40}}", "graph-lanes:new.csv": "[\"Гудвилл\"]", "graph-fold:old.csv": "[\"x\"]"}}, + {"name": "already-migrated", "op": "migrate", "before": {"graph-pos:board": "{}", "graph-pos:new.csv": "{\"AURORA-9001\":{\"x\":1,\"ry\":40}}"}}, + {"name": "mode-linked", "op": "startup", "before": {"graph-mode": "linked"}}, + {"name": "mode-all", "op": "startup", "before": {"graph-mode": "all"}} +] +``` + +- [ ] **Step 2: Генератор** — `web/scripts/golden-from-links.ts`: + +```ts +// разовый генератор golden-эталонов доски: исполняет ")); + +// элемент-заглушка: хранит стили, атрибуты и детей; querySelector возвращает один и тот же элемент на селектор; +// высота карточки 117 px, её заголовка (.title) 38 px, остальных элементов 0 +class El { + className = ""; hidden = false; disabled = false; title = ""; value = ""; textContent = ""; open = false; scrollLeft = 0; + style: Record = {}; + dataset: Record = {}; + attrs: Record = {}; + children: El[] = []; + sel = ""; + private html = ""; + private q = new Map(); + 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(), bySel = new Map(); +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(); +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 = { + 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 unknown>; +const g = vm.runInContext("__g", ctx) as G; +const scenarios = JSON.parse(read("scenarios.json")) as { name: string }[]; + +const basic = g.fromHub(JSON.parse(read("hub-basic.board.json"))); +write("hub-basic.expected.json", { + dicts: g.dicts(), + model: basic, + facets: { default: g.facets(basic), rememberedAll: g.facets(basic, []), rememberedSome: g.facets(basic, ["AWPCA_Спринт 3", "нет такого"]) }, + scenarios: Object.fromEntries(scenarios.map((sc) => [sc.name, g.scenario(basic, sc)])), + export: g.exportAfter(basic, scenarios[0]), +}); +const empty = g.fromHub(JSON.parse(read("hub-empty.board.json"))); +write("hub-empty.expected.json", { model: empty, scenario: g.scenario(empty, scenarios[0]) }); + +const csv = read("jira-export.csv"); +write("jira-export.expected.json", { rows: g.parseCsv(csv), model: g.fromCsv(csv, "jira-export.csv") }); +write("jira-no-links.expected.json", { model: g.fromCsv(read("jira-no-links.csv"), "jira-no-links.csv") }); +let bad: string | null = null; +try { g.fromCsv(read("jira-bad.csv"), "jira-bad.csv"); } catch (e) { bad = (e as Error).message; } +write("jira-bad.expected.json", { error: bad }); +write("old-board.expected.json", g.importJson(read("old-board.json"), "old-board.json")); + +const geo = JSON.parse(read("geometry.cases.json")) as { + edge: { a: unknown; b: unknown }[]; ortho: unknown[]; align: { pos: unknown; key: string; r: unknown; resizing: boolean }[]; +}; +write("geometry.expected.json", { + edge: geo.edge.map((c) => g.edgePath(c.a, c.b)), + ortho: geo.ortho.map((p) => g.orthoPath(p)), + align: geo.align.map((c) => g.align(c.pos, c.key, c.r, c.resizing)), +}); +const cases = JSON.parse(read("storage.cases.json")) as { name: string; op: string; before: unknown }[]; +write("storage.expected.json", Object.fromEntries(cases.map((c) => [c.name, g.storage(c.before, c.op)]))); +console.log("эталоны записаны в", FIX.pathname); +``` + +- [ ] **Step 3: Сгенерировать и проверить эталоны** + +Run: `cd web && npm run golden && git diff --stat -- src/board/__fixtures__ && npm run lint` +Expected: «эталоны записаны»; 8 файлов `*.expected.json`. Проверить руками (это и есть приёмка задачи): +- `hub-basic.expected.json` → `model.nodes` — 9 карточек (AURORA-9001…9009 без эпика 9100) и заглушка AURORA-9500 на + дорожке «Нет в зеркале»; `model.links` — 7 связей (9001→9002, 9003→9001, 9002→9005, 9004→9002, одна «связанные» + 9001–9008, 9006→9500, 9007→9009; без связи с эпиком, `Duplicate`, `lower-1` и обратного дубля «связанных»); + у AURORA-9004 `epic` = «Гудвилл», у AURORA-9008 — «AURORA-9999», у AURORA-9006 — «Без эпика»; +- `scenarios.crit.visible` — `crit: true` у AURORA-9003, 9001, 9002, 9005; +- `scenarios.default.visible.problems` — непустые `dates`, `unassigned`, `late`, `hand`; +- `scenarios.empty.emptyReason` начинается с «Фильтры не оставили ни одной задачи»; +- `scenarios.by-date.layout.dateCols` непустой; `scenarios.saved-folded.layout.lanes` содержит `folded: true` у «Гудвилл»; +- `jira-bad.expected.json` → `error` — «не нашёл колонки «Ключ проблемы» и «Тема»…»; +- `storage.expected.json` → `migrate-largest` перенёс `graph-pos:board` и `graph-lanes:board` из `new.csv`. + +Если какой-то пункт не выполняется — сначала поправить фикстуру (не генератор и не эталон руками) и перегенерировать. + +- [ ] **Step 4: Commit** + +```bash +git add web/scripts/golden-from-links.ts web/src/board/__fixtures__ +git commit -m "test(web): golden-эталоны доски из links.html@3931a88 на синтетических фикстурах (#21) + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- +### Task 6: доска — типы, константы, словари, разбор данных (`fromHub`, `fromCsv`, `fromJson`) + +**Модель:** sonnet (построчный перенос с типами). + +**Files:** +- Create: `web/src/board/types.ts`, `web/src/board/constants.ts`, `web/src/board/dicts.ts`, + `web/src/board/model/util.ts`, `web/src/board/model/csv.ts`, `web/src/board/model/fromHub.ts`, + `web/src/board/model/fromCsv.ts`, `web/src/board/model/fromJson.ts`, `web/src/board/model/links.ts` +- Test: `web/src/board/model/model.test.ts`, `web/src/board/dicts.test.ts`, `web/src/board/model/util.test.ts` + +**Interfaces:** +- Consumes: `HubSettings`, `BoardSnapshot` (Task 3), эталоны Task 5. +- Produces: + - `types.ts`: `BoardNode`, `Link`, `BoardModel`, `Dicts`, `Filters`, `noFilters(): Filters`, `VisNode`, `VisLink`, + `ProblemItem`, `Problems`, `Visible`, `SavedBox`, `JsonImport` (код ниже — имена полей не менять: они совпадают с + объектами старой страницы, это условие golden-сравнения). + - `dicts.ts`: `dictsFromSettings(s: HubSettings): {dicts: Dicts; missing: string[]}`. + - `model/util.ts`: `sortableDate`, `shortName`, `plural`, `fmtDate`, `dayDiff`, `notStarted`, `unassigned`, `whoKey`, + `fmtStamp`, `emptyModel(text)`, `esc` не нужен (React экранирует сам). + - `model/csv.ts`: `parseCsv(text): string[][]`. + - `model/links.ts`: `stubNode(key, lane)`, `addLink(links, seen, src, dst, type)`, `addStubs(nodes, links, lane?)`. + - `fromHub(snap: BoardSnapshot, dicts: Dicts): BoardModel`, `fromCsv(text, fileName, dicts): BoardModel`, + `fromJson(text, fileName, dicts): JsonImport`. + +- [ ] **Step 1: Типы** — `web/src/board/types.ts`: + +```ts +// модель доски связей; поля и их имена — как у links.html@3931a88 (условие golden-сравнения) +export interface BoardNode { + key: string; title: string; kind: string; prio: string; due: string; status: string; epic: string; sprint: string; + who: string; stub: boolean; +} +export interface Link { src: string; dst: string; type: string } +export interface BoardModel { + nodes: BoardNode[]; links: Link[]; sprints: Record; fileName: string; problems: string[]; + hasSprint: boolean; missing: number; stubLane?: string; emptyText?: string; +} +// kindLabels: метка Jira → вид работы; linkTypes: имя типа связи Jira → логический тип (обращённые словари /settings) +export interface Dicts { kindLabels: Record; linkTypes: Record } + +export interface Filters { + neighbors: boolean; onlyLinked: boolean; onlyHand: boolean; crit: boolean; byDate: boolean; + sprints: Set; who: Set; hideKind: Set; hideEdge: Set; +} +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; layoutByDate: Record; + dateColumns: Record | null; laneOrder: string[]; collapsedLanes: string[] | null; + filters: Record; +} +``` + +- [ ] **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` и т. п.); **не переносить** `KIND_LABELS`, `LINK_TYPES` (приходят из `/settings`), +`TOKEN_KEY`, `POLL_MS` (есть в `auth/token.ts` и Task 10), `NS`, `ARROWS` (разметка SVG — в компонентах). Добавить +`export const CPAD = 6;` (строка 403) и `export const EDGE_R = 10;` (строка 818). Комментарии строк переносятся. + +- [ ] **Step 3: Падающие тесты** + +`web/src/board/dicts.test.ts`: + +```ts +import { describe, expect, it } from "vitest"; +import golden from "./__fixtures__/hub-basic.expected.json"; +import settings from "./__fixtures__/settings.json"; +import { dictsFromSettings } from "./dicts.ts"; + +describe("dictsFromSettings", () => { + it("обращает словари настроек и совпадает со словарями старой страницы", () => { + const { dicts, missing } = dictsFromSettings(settings); + expect(dicts).toEqual(golden.dicts); + expect(missing).toEqual([]); + }); + it("называет пустые и отсутствующие словари", () => { + expect(dictsFromSettings({ kind_labels: {} }).missing).toEqual(["kind_labels", "link_types"]); + }); +}); +``` + +`web/src/board/model/model.test.ts`: + +```ts +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import type { BoardSnapshot } from "../../api/types.ts"; +import { canon } from "../../test/canon.ts"; +import { dictsFromSettings } from "../dicts.ts"; +import settings from "../__fixtures__/settings.json"; +import { parseCsv } from "./csv.ts"; +import { fromCsv } from "./fromCsv.ts"; +import { fromHub } from "./fromHub.ts"; +import { fromJson } from "./fromJson.ts"; + +const fx = (name: string) => readFileSync(new URL(`../__fixtures__/${name}`, import.meta.url), "utf8"); +const golden = (name: string) => JSON.parse(fx(name)); +const { dicts } = dictsFromSettings(settings); + +describe("модель доски = links.html@3931a88", () => { + it("fromHub — снимок с эпиками, соседями, заглушками", () => { + const snap = JSON.parse(fx("hub-basic.board.json")) as BoardSnapshot; + expect(canon(fromHub(snap, dicts))).toEqual(golden("hub-basic.expected.json").model); + }); + it("fromHub — пустое зеркало без полного прогона", () => { + const snap = JSON.parse(fx("hub-empty.board.json")) as BoardSnapshot; + expect(canon(fromHub(snap, dicts))).toEqual(golden("hub-empty.expected.json").model); + }); + it("parseCsv и fromCsv — BOM, CRLF, кавычки, повторные колонки, даты разных локалей", () => { + const text = fx("jira-export.csv"); + const g = golden("jira-export.expected.json"); + expect(parseCsv(text)).toEqual(g.rows); + expect(canon(fromCsv(text, "jira-export.csv", dicts))).toEqual(g.model); + }); + it("fromCsv без колонок связей, спринта и эпика — проблемы файла", () => { + expect(canon(fromCsv(fx("jira-no-links.csv"), "jira-no-links.csv", dicts))).toEqual(golden("jira-no-links.expected.json").model); + }); + it("fromCsv без ключа и темы — та же ошибка", () => { + expect(() => fromCsv(fx("jira-bad.csv"), "jira-bad.csv", dicts)).toThrow(golden("jira-bad.expected.json").error); + }); + it("fromJson — выгрузка старой страницы", () => { + expect(canon(fromJson(fx("old-board.json"), "old-board.json", dicts))).toEqual(golden("old-board.expected.json").import); + }); + it("fromJson — не JSON и JSON без nodes", () => { + expect(() => fromJson("{", "x.json", dicts)).toThrow("это не JSON"); + expect(() => fromJson("{}", "x.json", dicts)).toThrow("в JSON нет списка nodes"); + }); +}); +``` + +`web/src/board/model/util.test.ts`: + +```ts +import { describe, expect, it } from "vitest"; +import { dayDiff, fmtDate, plural, shortName, sortableDate } from "./util.ts"; + +describe("util", () => { + it("sortableDate приводит даты Jira к ГГГГ-ММ-ДД", () => { + expect(sortableDate("2026-10-20T10:00:00")).toBe("2026-10-20"); + expect(sortableDate("5.1.2026")).toBe("2026-01-05"); + expect(sortableDate("20/окт/26")).toBe("2026-10-20"); + expect(sortableDate("10/Oct/2026")).toBe("2026-10-10"); + expect(sortableDate("потом")).toBe("потом"); + }); + it("shortName, plural, fmtDate, dayDiff", () => { + expect(shortName("Степанов Максим Александрович")).toBe("Степанов М. А."); + expect(shortName("Stepanov-MAl")).toBe("Stepanov-MAl"); + expect(shortName("")).toBe("не назначен"); + expect(plural(21, "задача", "задачи", "задач")).toBe("21 задача"); + expect(plural(12, "задача", "задачи", "задач")).toBe("12 задач"); + expect(fmtDate("2026-10-20")).toBe("20.10"); + expect(dayDiff("2026-10-14", "2026-10-20")).toBe(6); + expect(dayDiff("", "2026-10-20")).toBeNull(); + }); +}); +``` + +Run: `cd web && npx vitest run src/board` +Expected: FAIL — модулей нет. + +- [ ] **Step 4: Перенос** (ревизия 3931a88; правила: глобальные `KIND_LABELS`/`LINK_TYPES` → `dicts.kindLabels`/ +`dicts.linkTypes`; `throw new Error(…)` и тексты — без изменений; `const`-стрелочные функции страницы → `export function` +с типами параметров): +- `model/util.ts` ← строки 336–365 (`plural`, `fmtDate`, `dayDiff`, `notStarted`, `unassigned`, `whoKey`, `shortName`, + `sortableDate`), строка 1030 (`fmtStamp`), строка 1031 (`emptyModel(text: string): BoardModel`); `esc` и `$` не + переносить. +- `model/csv.ts` ← строки 411–424 (`parseCsv`). +- `model/links.ts` ← строки 425–437 (`stubNode`, `addLink(links: Link[], seen: Set, …)`, `addStubs`). +- `model/fromCsv.ts` ← строки 442–482 (`fromJiraCsv` → `fromCsv(text, fileName, dicts)`; в строке 456 перебор + `Object.entries(dicts.linkTypes)`, в строке 469 — `dicts.kindLabels[v]`). +- `model/fromJson.ts` ← строки 484–508 (`fromJson(text, fileName, dicts)`; строка 499 — + `new Set(Object.values(dicts.linkTypes))`; `strList` — из `storage.ts` ещё нет, поэтому объявить локально + `const strList = (v: unknown): string[] => Array.isArray(v) ? v.filter((s): s is string => typeof s === "string") : [];` + — в Task 9 этот же помощник экспортируется из `storage.ts`, а здесь импортируется оттуда). +- `model/fromHub.ts` ← строки 513–533 (`fromHub(snap, dicts)`; строки 520–522 — `dicts.kindLabels`, строка 527 — + `dicts.linkTypes[l.link_type]`). + +`web/src/board/dicts.ts`: + +```ts +import type { HubSettings } from "../api/types.ts"; +import type { Dicts } from "./types.ts"; + +// словари доски из настроек хаба: kind_labels (вид → метка) и link_types (логический тип → имя Jira) обращаются +// в «метка → вид» и «имя Jira → логический тип»; пустой словарь — в missing (доска сообщит, чего не хватает) +const invert = (o: Record | undefined) => Object.fromEntries(Object.entries(o ?? {}).map(([k, v]) => [v, k])); + +export function dictsFromSettings(s: HubSettings): { dicts: Dicts; missing: string[] } { + const dicts = { kindLabels: invert(s.kind_labels), linkTypes: invert(s.link_types) }; + const missing = [["kind_labels", dicts.kindLabels], ["link_types", dicts.linkTypes]] + .filter(([, d]) => Object.keys(d as object).length === 0).map(([n]) => n as string); + return { dicts, missing }; +} +``` + +- [ ] **Step 5: Прогнать** + +Run: `cd web && npm run lint && npx vitest run src/board` +Expected: PASS — 11 тестов. Расхождение с эталоном — ошибка переноса: сравнить построчно с ревизией 3931a88, эталон не править. + +- [ ] **Step 6: Commit** + +```bash +git add web/src/board +git commit -m "feat(web): модель доски — разбор снимка хаба, CSV и JSON, словари из настроек (#21) + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### 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; edges: Set}`; + - `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; items: FacetItem[]}`, `whoItems(model, f)` — то же; + - `initialSprints(model: BoardModel, f: Filters, remembered: unknown): Set`, + `initialWho(model: BoardModel, f: Filters, remembered: unknown): Set`; + - `whoBox(model, f): {kind: "none"} | {kind: "text"; text: string} | {kind: "multi"}` и + `sprintBox(model): {kind: "none"} | {kind: "text"; text: string} | {kind: "multi"}` — что показывать вместо + выпадающего списка (строки 953–954, 967–969). + +- [ ] **Step 1: Помощник сценариев** — `web/src/test/boardScenarios.ts` (общий для тестов анализа и раскладки; в +тестовый файл не кладётся, иначе импорт запустит чужие тесты): + +```ts +import { noFilters, type Filters } from "../board/types.ts"; + +// фильтры сценария из __fixtures__/scenarios.json: отсутствующие флаги — false, множества — пустые +export function filtersOf(sc: { filters: Record }): Filters { + const f = noFilters(), src = sc.filters; + for (const k of ["neighbors", "onlyLinked", "onlyHand", "crit", "byDate"] as const) f[k] = !!src[k]; + for (const k of ["sprints", "who", "hideKind", "hideEdge"] as const) f[k] = new Set((src[k] as string[] | undefined) ?? []); + return f; +} +``` + +- [ ] **Step 2: Падающие тесты** — `web/src/board/analysis/analysis.test.ts`: + +```ts +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import type { BoardSnapshot } from "../../api/types.ts"; +import { canon } from "../../test/canon.ts"; +import { dictsFromSettings } from "../dicts.ts"; +import settings from "../__fixtures__/settings.json"; +import scenarios from "../__fixtures__/scenarios.json"; +import { fromHub } from "../model/fromHub.ts"; +import { filtersOf } from "../../test/boardScenarios.ts"; +import { noFilters } from "../types.ts"; +import { initialSprints, initialWho, sprintItems, whoItems } from "./facets.ts"; +import { emptyReason, computeVisible } from "./visible.ts"; + +const fx = (name: string) => readFileSync(new URL(`../__fixtures__/${name}`, import.meta.url), "utf8"); +const golden = JSON.parse(fx("hub-basic.expected.json")); +const { dicts } = dictsFromSettings(settings); +const model = fromHub(JSON.parse(fx("hub-basic.board.json")) as BoardSnapshot, dicts); +const TODAY = "2026-10-14"; + +type Sc = { name: string; filters: Record; laneOrder?: string[] }; + +describe("выборка = links.html@3931a88", () => { + for (const sc of scenarios as Sc[]) { + it(`computeVisible: ${sc.name}`, () => { + const f = filtersOf(sc); + const vis = computeVisible(model, f, sc.laneOrder ?? [], TODAY); + const g = golden.scenarios[sc.name]; + expect(canon(vis)).toEqual(g.visible); + expect(vis.nodes.length ? null : emptyReason(model, vis, f)).toEqual(g.emptyReason); + }); + } + + it("пустое зеркало — своя причина", () => { + const empty = fromHub(JSON.parse(fx("hub-empty.board.json")) as BoardSnapshot, dicts); + const vis = computeVisible(empty, noFilters(), [], TODAY); + expect(emptyReason(empty, vis, noFilters())).toBe(JSON.parse(fx("hub-empty.expected.json")).scenario.emptyReason); + }); + + it("счётчики и начальный выбор спринтов и исполнителей", () => { + const f = noFilters(); + const check = (remembered: unknown, g: { sprintItems: unknown; whoItems: unknown; sprints: unknown; who: unknown }) => { + expect(canon(sprintItems(model, f).items)).toEqual(g.sprintItems); + expect(canon(whoItems(model, f).items)).toEqual(g.whoItems); + expect(canon(initialSprints(model, f, remembered))).toEqual(g.sprints); + expect(canon(initialWho(model, f, []))).toEqual(g.who); + }; + check(null, golden.facets.default); + check([], golden.facets.rememberedAll); + check(["AWPCA_Спринт 3", "нет такого"], golden.facets.rememberedSome); + }); +}); +``` + +Run: `cd web && npx vitest run src/board/analysis` → FAIL (модулей нет). + +- [ ] **Step 3: Перенос** (глобальные `S`, `MODEL`, `laneOrder()`, `new Date()` → параметры `f`, `model`, `laneOrder`, +`today`): +- `analysis/visible.ts` ← строки 538–572 (`passes`, `edgeClass`, `computeVisible`) и 608–617 (`emptyReason`, + `VIS.removed` → `vis.removed`); `computeVisible` вызывает `criticalPath` и `findProblems(nodes, links, today)`. +- `analysis/critical.ts` ← строки 574–592. +- `analysis/problems.ts` ← строки 594–607; `today` — параметр вместо `new Date().toISOString().slice(0, 10)`. +- `analysis/facets.ts` ← строки 941–949 (`facetItems`, `sprintItems`, `whoItems` с параметрами `model`, `f`), + строки 955–961 (`initialSprints`: пустая модель или `!hasSprint` → пустое множество; иначе логика строк 955–961 с + `remembered` вместо `store.json(fileKey("sprint"), null)`), строка 970 (`initialWho`: `remembered` вместо + `store.json(fileKey("who"), [])`, пустая модель → пустое множество), строки 953–954 и 967–969 (`sprintBox`, `whoBox` + — тексты «колонки «Спринт» нет — показаны все задачи», «все задачи без исполнителя», «колонки «Исполнитель» нет»). + +- [ ] **Step 4: Прогнать** + +Run: `cd web && npm run lint && npx vitest run src/board` +Expected: PASS — 12 сценариев + 2 теста анализа + тесты Task 6. + +- [ ] **Step 5: Commit** + +```bash +git add web/src/board/analysis web/src/test/boardScenarios.ts +git commit -m "feat(web): выборка доски, критический путь, проблемы, счётчики фильтров (#21) + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 8: доска — раскладка, стрелки, примагничивание + +**Модель:** sonnet. + +**Files:** +- Create: `web/src/board/layout/layout.ts`, `web/src/board/layout/edges.ts`, `web/src/board/layout/align.ts` +- Test: `web/src/board/layout/layout.test.ts` + +**Interfaces:** +- Consumes: `Visible`, `VisNode`, `SavedBox`, константы (Task 6–7). +- Produces: + +```ts +export interface Rect { x: number; y: number; w: number; h: number } +export interface RelBox { x: number; ry: number; w: number; h: number; auto: boolean; other?: number } +export interface DateCol { date: string; x0: number; w: number; count: number; cls: "" | "past" | "today" | "none" } +export interface Layout { + rel: Record; // раскладка карточек относительно дорожек (начальная; правки — у вызывающего) + laneOf: Record; // индекс дорожки карточки + colBounds: Record; // колонка даты карточки (только «по датам») + 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; width: number; height: number } + +export function measuredHeight(offsetHeight: number): number; // строка 675 +export function computeLayout(vis: Visible, o: { + byDate: boolean; colWidths: Record; saved: unknown; today: string; + measure: (key: string) => { h: number; other: number }; // h — уже measuredHeight(offsetHeight) при ширине CW +}): Layout; +export function place(layout: Layout, rel: Record, folded: Set): 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, key: string, r: Rect, resizing: boolean): + { x: number; y: number; w: number; h: number; snappedX: boolean; snappedY: boolean; guideX: number | null; guideY: number | null }; +``` + +- [ ] **Step 1: Падающие тесты** — `web/src/board/layout/layout.test.ts`: + +```ts +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import type { BoardSnapshot } from "../../api/types.ts"; +import { canon } from "../../test/canon.ts"; +import { computeVisible } from "../analysis/visible.ts"; +import { filtersOf } from "../../test/boardScenarios.ts"; +import { dictsFromSettings } from "../dicts.ts"; +import settings from "../__fixtures__/settings.json"; +import scenarios from "../__fixtures__/scenarios.json"; +import { fromHub } from "../model/fromHub.ts"; +import { alignEdges } from "./align.ts"; +import { edgePath, orthoPath } from "./edges.ts"; +import { computeLayout, dateText, dateTitle, lineClamp, measuredHeight, place, type Rect } from "./layout.ts"; + +const fx = (name: string) => readFileSync(new URL(`../__fixtures__/${name}`, import.meta.url), "utf8"); +const golden = JSON.parse(fx("hub-basic.expected.json")); +const geo = JSON.parse(fx("geometry.cases.json")); +const geoG = JSON.parse(fx("geometry.expected.json")); +const { dicts } = dictsFromSettings(settings); +const model = fromHub(JSON.parse(fx("hub-basic.board.json")) as BoardSnapshot, dicts); +const TODAY = "2026-10-14"; +const measure = () => ({ h: measuredHeight(117), other: 117 - 38 }); // как заглушка DOM генератора эталонов + +type Sc = { name: string; filters: Record; laneOrder?: string[]; folded?: string[]; + saved?: unknown; savedDate?: unknown; colWidths?: Record }; + +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)}${c.count}` }))).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; key: string; r: Rect; resizing: boolean }, i: number) => { + const { guideX, guideY, ...out } = alignEdges(c.pos, c.key, c.r, c.resizing); + expect(out).toEqual(geoG.align[i].out); + const guides = [...(guideX === null ? [] : [{ x1: guideX, y1: 0, x2: guideX, y2: 0 }]), + ...(guideY === null ? [] : [{ x1: 0, y1: guideY, x2: 0, y2: guideY }])]; + expect(guides).toEqual(geoG.align[i].guides); + }); + }); +}); +``` + +Высота и ширина доски в заглушке DOM равны 0, поэтому эталонные направляющие идут от 0 до 0; в компоненте вместо 0 +подставляются размеры доски. + +Run: `cd web && npx vitest run src/board/layout` → FAIL. + +- [ ] **Step 2: Перенос** +- `layout/layout.ts` ← `render()` строки 635–672 без DOM: `layer`, `dateCols`, `layerCols`, `colW`, `layerX`, `baseW`, + стопки (`stacks`), `laneOf`, начальные `rel`; вместо `renderLane` — `laneNames`; вместо `renderDateCols` — `dateCols` + с `x0 = layerX[c] - LEFT / 2`, `w = colW(c)`, `count`, `cls` (строки 679–682, `today` — параметр); `colBounds` + (строка 660); `multiSprint` (строка 661); измерение (строка 664) — `measure(key)` даёт `h` и `other`; стопки + (строка 665); сохранённая раскладка (строки 666–672) — из параметра `saved`. `measuredHeight` ← строка 675. + `place` ← `relayout()` строки 785–806 без DOM: возвращает `lanes`, `pos`, `width = max(baseW, …)`, `height = yy`. + `clampToCol` ← строки 403–408 (`colBounds` — параметр). `lineClamp` ← выражение строки 800. + `dateTitle`/`dateText` ← строки 686–687. +- `layout/edges.ts` ← строки 818–837. +- `layout/align.ts` ← строки 851–865 без отрисовки линий: `guideX = bx ? bx.t : null`, `guideY = by ? by.t : null`. + +- [ ] **Step 3: Прогнать** + +Run: `cd web && npm run lint && npx vitest run src/board` +Expected: PASS — 12 сценариев раскладки + 2 теста геометрии + прежние. + +- [ ] **Step 4: Commit** + +```bash +git add web/src/board/layout +git commit -m "feat(web): раскладка доски, стрелки и примагничивание — без DOM (#21) + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 9: доска — хранилище настроек, импорт и экспорт JSON + +**Модель:** sonnet. + +**Files:** +- Create: `web/src/board/storage.ts`, `web/src/board/exportJson.ts` +- Modify: `web/src/board/model/fromJson.ts` (локальный `strList` → импорт из `storage.ts`) +- Test: `web/src/board/storage.test.ts` + +**Interfaces:** +- Consumes: `Filters`, `JsonImport`, `BoardModel`, `Visible`, `RelBox`, `Layout["colBounds"]`. +- Produces: + +```ts +export const store: { get(k: string, d: string | null): string | null; set(k: string, v: string): void; del(k: string): void; + json(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; +export function colWidths(): Record; +export function savedLayout(byDate: boolean): unknown; // store.json(fileKey(byDate ? "posdate" : "pos"), null) +export function saveLayout(rel: Record, 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, hideEdge: Set): void; +export function rememberedSprints(): unknown; // store.json(fileKey("sprint"), null) +export function rememberedWho(): unknown; // store.json(fileKey("who"), []) +export function writeSprints(s: Set): void; +export function writeWho(s: Set): 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; // строки 1130–1149: пишет хранилище, возвращает изменённые фильтры +// exportJson.ts +export function exportJson(model: BoardModel, vis: Visible, f: Filters, now: string): unknown; // строки 1172–1184 (без saveLayout — его вызывает компонент перед экспортом) +export function exportFileName(model: BoardModel, now: string): string; // строка 1191 +``` + +Ключи флагов в хранилище — как у страницы: `neighbors` → `graph-neighbors`, `onlyLinked` → `graph-onlylinked`, +`onlyHand` → `graph-onlyhand`, `crit` → `graph-crit`, `byDate` → `graph-bydate`, `snap` → `graph-snap` +(по умолчанию `true`), `table` → `graph-tbl`, `problems` → `graph-prob`; скрытые — `graph-hidekind`, `graph-hideedge`. + +- [ ] **Step 1: Падающие тесты** — `web/src/board/storage.test.ts`: + +```ts +import { readFileSync } from "node:fs"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { BoardSnapshot } from "../api/types.ts"; +import { canon } from "../test/canon.ts"; +import { computeVisible } from "./analysis/visible.ts"; +import { dictsFromSettings } from "./dicts.ts"; +import { exportJson } from "./exportJson.ts"; +import settings from "./__fixtures__/settings.json"; +import storageCases from "./__fixtures__/storage.cases.json"; +import { computeLayout, measuredHeight } from "./layout/layout.ts"; +import { fromHub } from "./model/fromHub.ts"; +import { fromJson } from "./model/fromJson.ts"; +import { applyJsonSettings, migrateMode, migrateStore, readPrefs, saveLayout, setColWidth, writeFlag } from "./storage.ts"; +import { noFilters } from "./types.ts"; + +const fx = (name: string) => readFileSync(new URL(`./__fixtures__/${name}`, import.meta.url), "utf8"); +const { dicts } = dictsFromSettings(settings); +const NOW = "2026-10-14T12:00:00.000Z"; +const dump = () => Object.fromEntries(Object.keys(localStorage).map((k) => [k, localStorage.getItem(k)])); + +describe("хранилище = links.html@3931a88", () => { + beforeEach(() => localStorage.clear()); + + const expected = JSON.parse(fx("storage.expected.json")); + for (const c of storageCases as { name: string; op: string; before: Record }[]) { + 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`; `drawLegend` не нужен), строки 807–814 (`toggleLane`, `moveLane` — только запись в + хранилище; перерисовку делает компонент), строки 691/699 (`setColWidth`), строка 1012 (`resetLayout`). Обращения к + `localStorage.length/key` в `migrateStore` — в `try/catch`, как в строке 384. +- `exportJson.ts` ← строки 1172–1184 (`MODEL`, `VIS`, `S` → параметры; `new Date().toISOString()` → `now`; + `saveLayout()` из первой строки не вызывать) и строка 1191 (`exportFileName`). +- `model/fromJson.ts` — удалить локальный `strList`, импортировать из `../storage.ts`. + +- [ ] **Step 3: Прогнать** + +Run: `cd web && npm run lint && npm test` +Expected: PASS — 8 тестов хранилища + прежние. + +- [ ] **Step 4: Commit** + +```bash +git add web/src/board +git commit -m "feat(web): хранилище настроек доски с прежними ключами, импорт и экспорт JSON (#21) + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- +### Task 10: доска — источник данных и «Обновить из Jira» (M-1…M-5) + +**Модель:** sonnet (интеграция TanStack Query, асинхронные тесты). + +**Files:** +- Create: `web/src/board/useBoardSource.ts`, `web/src/board/status.ts` +- Test: `web/src/board/useBoardSource.test.tsx`, `web/src/board/status.test.ts` + +**Interfaces:** +- Consumes: `api`, `AuthError`, `HubDownError`, `ApiError`, `useBoard`, `qk` (Task 3); `clearToken` (Task 3); `fromHub`, + `fromCsv`, `fromJson`, `emptyModel`, `fmtStamp` (Task 6); типы `Batch`, `AgentView`, `CreatedBatch`, `SyncRun`. +- Produces: + +```ts +// status.ts +export const POLL_MS = 3000; +export const MAX_POLL_ERRORS = 5; +export type BoardStatus = + | { kind: "loading" } + | { kind: "batch"; id: number; phase: "queued" | "running"; remaining: number; agentOffline: boolean; retrying: boolean } + | { kind: "batch-done"; id: number } + | { kind: "batch-failed"; id: number; error: string } + | { kind: "batch-cancelled"; id: number } + | { kind: "batch-lost"; id: number; reason: string } + | { kind: "hub-down"; hasData: boolean } + | { kind: "file-error"; fileName: string; reason: string } + | { kind: "json-applied"; fileName: string } + | { kind: "error"; message: string }; +export function statusText(s: BoardStatus): { text: string; warn: boolean }; +// useBoardSource.ts +export interface BoardSource { + model: BoardModel; // всегда есть: при загрузке и без данных — emptyModel с причиной + source: "hub" | "file"; + label: string; // источник: «Хаб · данные на 14.10 12:01», «sprint2.csv (из файла)» + title: string; // подсказка к label (строка 1074 links.html) + status: BoardStatus | null; // одно текущее событие строки состояния (M-5) + refreshing: boolean; // «Обновить из Jira» недоступна (M-2) + reload(): void; // «Перечитать»: источник — хаб, снимок заново (M-1) + refreshFromJira(): Promise; + loadFile(fileName: string, text: string): JsonImport | null; // JSON — вернёт настройки для применения +} +export function useBoardSource(dicts: Dicts | null, opts?: { pollMs?: number }): BoardSource; +``` + +Подписи строки состояния (`statusText`): + +| `kind` | `text` | `warn` | +|---|---|---| +| `loading` | `загрузка данных хаба…` | нет | +| `batch` | `[агент офлайн — пакет выполнится, когда он выйдет на связь; ]пакет #N в очереди|выполняется[, осталось ~R с]…[ (хаб не отвечает, повтор через 3 с)]` | только при `retrying` | +| `batch-done` | `пакет #N выполнен` | нет | +| `batch-failed` | `пакет #N не выполнен: ` | да | +| `batch-cancelled` | `пакет #N отменён` | да | +| `batch-lost` | `пакет #N: — опрос остановлен, «Обновить из Jira» снова доступна` | да | +| `hub-down` | `hasData` ? `хаб не отвечает — показаны прежние данные` : `хаб не отвечает: запустите хаб (jira-hub serve) и нажмите «Перечитать»` | да | +| `file-error` | `файл не разобран: ` | да | +| `json-applied` | `раскладка и фильтры из применены к данным хаба` | нет | +| `error` | `` | да | + +- [ ] **Step 1: Тест подписей** — `web/src/board/status.test.ts`: + +```ts +import { describe, expect, it } from "vitest"; +import { statusText } from "./status.ts"; + +describe("statusText", () => { + it("пакет: офлайн, очередь, остаток, повтор", () => { + expect(statusText({ kind: "batch", id: 7, phase: "queued", remaining: 12, agentOffline: true, retrying: false })) + .toEqual({ text: "агент офлайн — пакет выполнится, когда он выйдет на связь; пакет #7 в очереди, осталось ~12 с…", warn: false }); + expect(statusText({ kind: "batch", id: 7, phase: "running", remaining: 0, agentOffline: false, retrying: true })) + .toEqual({ text: "пакет #7 выполняется… (хаб не отвечает, повтор через 3 с)", warn: true }); + }); + it("исходы и ошибки", () => { + expect(statusText({ kind: "batch-failed", id: 7, error: "429" })).toEqual({ text: "пакет #7 не выполнен: 429", warn: true }); + expect(statusText({ kind: "batch-lost", id: 7, reason: "пакета нет в хабе" }).text) + .toBe("пакет #7: пакета нет в хабе — опрос остановлен, «Обновить из Jira» снова доступна"); + expect(statusText({ kind: "hub-down", hasData: true }).text).toBe("хаб не отвечает — показаны прежние данные"); + expect(statusText({ kind: "file-error", fileName: "a.csv", reason: "в файле нет строк с задачами" }).text) + .toBe("файл не разобран: в файле нет строк с задачами"); + }); +}); +``` + +`web/src/board/status.ts`: + +```ts +export const POLL_MS = 3000; // период опроса пакета, мс (как у links.html) +export const MAX_POLL_ERRORS = 5; // столько ошибок «хаб не отвечает» подряд — и опрос пакета останавливается (M-4) + +export type BoardStatus = + | { kind: "loading" } + | { kind: "batch"; id: number; phase: "queued" | "running"; remaining: number; agentOffline: boolean; retrying: boolean } + | { kind: "batch-done"; id: number } + | { kind: "batch-failed"; id: number; error: string } + | { kind: "batch-cancelled"; id: number } + | { kind: "batch-lost"; id: number; reason: string } + | { kind: "hub-down"; hasData: boolean } + | { kind: "file-error"; fileName: string; reason: string } + | { kind: "json-applied"; fileName: string } + | { kind: "error"; message: string }; + +// одна строка состояния доски: текст и признак предупреждения (M-5) +export function statusText(s: BoardStatus): { text: string; warn: boolean } { + switch (s.kind) { + case "loading": return { text: "загрузка данных хаба…", warn: false }; + case "batch": { + const off = s.agentOffline ? "агент офлайн — пакет выполнится, когда он выйдет на связь; " : ""; + const left = s.remaining ? `, осталось ~${s.remaining} с` : ""; + const retry = s.retrying ? ` (хаб не отвечает, повтор через ${POLL_MS / 1000} с)` : ""; + return { text: `${off}пакет #${s.id} ${s.phase === "queued" ? "в очереди" : "выполняется"}${left}…${retry}`, warn: s.retrying }; + } + case "batch-done": return { text: `пакет #${s.id} выполнен`, warn: false }; + case "batch-failed": return { text: `пакет #${s.id} не выполнен: ${s.error}`, warn: true }; + case "batch-cancelled": return { text: `пакет #${s.id} отменён`, warn: true }; + case "batch-lost": return { text: `пакет #${s.id}: ${s.reason} — опрос остановлен, «Обновить из Jira» снова доступна`, warn: true }; + case "hub-down": return { + text: s.hasData ? "хаб не отвечает — показаны прежние данные" : "хаб не отвечает: запустите хаб (jira-hub serve) и нажмите «Перечитать»", + warn: true, + }; + case "file-error": return { text: `файл не разобран: ${s.reason}`, warn: true }; + case "json-applied": return { text: `раскладка и фильтры из ${s.fileName} применены к данным хаба`, warn: false }; + case "error": return { text: s.message, warn: true }; + } +} +``` + +- [ ] **Step 2: Падающие тесты хука** — `web/src/board/useBoardSource.test.tsx`: + +```tsx +import { QueryClientProvider } from "@tanstack/react-query"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import { readFileSync } from "node:fs"; +import type { ReactNode } from "react"; +import { beforeEach, describe, expect, it } from "vitest"; +import { makeQueryClient } from "../api/queryClient.ts"; +import { setToken } from "../auth/token.ts"; +import { json, mockFetch } from "../test/fetchMock.ts"; +import { dictsFromSettings } from "./dicts.ts"; +import settings from "./__fixtures__/settings.json"; +import { useBoardSource } from "./useBoardSource.ts"; + +const fx = (name: string) => readFileSync(new URL(`./__fixtures__/${name}`, import.meta.url), "utf8"); +const SNAP = JSON.parse(fx("hub-basic.board.json")); +const CSV = fx("jira-export.csv"); +const { dicts } = dictsFromSettings(settings); +const wrapper = ({ children }: { children: ReactNode }) => + {children}; +const hook = () => renderHook(() => useBoardSource(dicts, { pollMs: 10 }), { wrapper }); +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); +const deferred = () => { let resolve!: (v: T) => void; const promise = new Promise((r) => { resolve = r; }); return { promise, resolve }; }; + +// хаб в памяти: снимок, список пакетов, пакет по id; счётчики запросов +function hub(over: Partial Response | Promise>> = {}) { + const calls: string[] = []; + const inFlight: Record = {}, maxInFlight: Record = {}; + 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 = { 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(); + const h = hub({ "GET /board": () => slow.promise }); + const { result } = hook(); + act(() => { result.current.loadFile("jira-export.csv", CSV); }); + expect(result.current.source).toBe("file"); + slow.resolve(json(200, SNAP)); + await waitFor(() => expect(h.count("GET /board")).toBe(1)); + await sleep(20); + expect(result.current.source).toBe("file"); + expect(result.current.label).toBe("jira-export.csv (из файла)"); + act(() => result.current.reload()); + await waitFor(() => expect(result.current.source).toBe("hub")); + await waitFor(() => expect(result.current.model.nodes.length).toBe(10)); + }); + + it("M-2: двойное нажатие — один пакет и один опрос за раз", async () => { + let polls = 0; + const h = hub({ "GET /batches/:id": () => (++polls < 4 ? batch("running") : batch("done")) }); + const { result } = hook(); + await waitFor(() => expect(result.current.model.nodes.length).toBe(10)); + await act(async () => { void result.current.refreshFromJira(); await result.current.refreshFromJira(); }); + expect(h.count("POST /refresh")).toBe(1); + expect(result.current.refreshing).toBe(true); + await waitFor(() => expect(result.current.status).toEqual({ kind: "batch-done", id: 7 })); + expect(result.current.refreshing).toBe(false); + expect(h.maxInFlight("GET /batches/7")).toBe(1); + expect(h.count("GET /board")).toBe(2); // по завершении снимок перечитан + }); + + it("M-3: незавершённый пакет viewer подхватывается и после «Перечитать»", async () => { + let listed: unknown[] = []; + const h = hub({ "GET /batches": () => json(200, { batches: listed }), "GET /batches/:id": () => batch("running") }); + const { result } = hook(); + await waitFor(() => expect(h.count("GET /batches")).toBe(1)); + expect(result.current.refreshing).toBe(false); + listed = [{ id: 7, author: "viewer", status: "running" }, { id: 6, author: "chat", status: "running" }]; + act(() => result.current.reload()); + await waitFor(() => expect(result.current.refreshing).toBe(true)); + await waitFor(() => expect(result.current.status).toMatchObject({ kind: "batch", id: 7, phase: "running" })); + }); + + it("M-4: 404 на опросе — опрос остановлен, кнопка доступна", async () => { + const h = hub({ "GET /batches/:id": () => json(404, { error: "not_found", detail: "batch" }) }); + const { result } = hook(); + await waitFor(() => expect(result.current.model.nodes.length).toBe(10)); + await act(() => result.current.refreshFromJira()); + await waitFor(() => expect(result.current.status).toMatchObject({ kind: "batch-lost", id: 7 })); + expect(result.current.refreshing).toBe(false); + const n = h.count("GET /batches/7"); + await sleep(50); + expect(h.count("GET /batches/7")).toBe(n); + }); + + it("M-4: 5 ошибок «хаб не отвечает» подряд — опрос остановлен", async () => { + const h = hub({ "GET /batches/:id": () => { throw new TypeError("Failed to fetch"); } }); + const { result } = hook(); + await waitFor(() => expect(result.current.model.nodes.length).toBe(10)); + await act(() => result.current.refreshFromJira()); + await waitFor(() => expect(result.current.status).toMatchObject({ kind: "batch-lost", reason: "хаб не отвечает" })); + expect(h.count("GET /batches/7")).toBe(5); + await sleep(50); + expect(h.count("GET /batches/7")).toBe(5); + expect(result.current.refreshing).toBe(false); + }); + + it("пакет с ошибкой команды — текст ошибки; агент офлайн — предупреждение в статусе", async () => { + let polls = 0; + hub({ + "GET /agent": () => json(200, { online: false, last_seen_at: null, warning: null }), + "GET /batches/:id": () => (++polls < 3 ? batch("running") : batch("done", { status: "failed", error: "Jira 500" })), + }); + const { result } = hook(); + await waitFor(() => expect(result.current.model.nodes.length).toBe(10)); + await act(() => result.current.refreshFromJira()); + await waitFor(() => expect(result.current.status).toMatchObject({ kind: "batch", agentOffline: true })); + await waitFor(() => expect(result.current.status).toEqual({ kind: "batch-failed", id: 7, error: "Jira 500" })); + }); + + it("M-1: пакет, завершившийся при показанном файле, не заменяет файл", async () => { + let polls = 0; + hub({ "GET /batches/:id": () => (++polls < 3 ? batch("running") : batch("done")) }); + const { result } = hook(); + await waitFor(() => expect(result.current.model.nodes.length).toBe(10)); + await act(() => result.current.refreshFromJira()); + act(() => { result.current.loadFile("jira-export.csv", CSV); }); + await waitFor(() => expect(result.current.status).toEqual({ kind: "batch-done", id: 7 })); + expect(result.current.source).toBe("file"); + }); + + it("хаб остановлен при «Перечитать» — прежние данные и hub-down", async () => { + let down = false; + hub({ "GET /board": () => { if (down) throw new TypeError("Failed to fetch"); return json(200, SNAP); } }); + const { result } = hook(); + await waitFor(() => expect(result.current.model.nodes.length).toBe(10)); + down = true; + act(() => result.current.reload()); + await waitFor(() => expect(result.current.status).toEqual({ kind: "hub-down", hasData: true })); + expect(result.current.model.nodes.length).toBe(10); + }); + + it("плохой файл — file-error, прежняя доска на месте", async () => { + hub(); + const { result } = hook(); + await waitFor(() => expect(result.current.model.nodes.length).toBe(10)); + act(() => { result.current.loadFile("bad.csv", "Номер,Название\n1,x\n"); }); + expect(result.current.status).toMatchObject({ kind: "file-error", fileName: "bad.csv" }); + expect(result.current.source).toBe("hub"); + }); + + it("импорт JSON при живом хабе — настройки возвращаются, данные из хаба", async () => { + hub(); + const { result } = hook(); + await waitFor(() => expect(result.current.model.nodes.length).toBe(10)); + let r: unknown = null; + act(() => { r = result.current.loadFile("old-board.json", fx("old-board.json")); }); + expect(r).toMatchObject({ laneOrder: ["Гудвилл", "Тестовый эпик"] }); + expect(result.current.source).toBe("hub"); + expect(result.current.status).toEqual({ kind: "json-applied", fileName: "old-board.json" }); + }); +}); +``` + +Run: `cd web && npx vitest run src/board/useBoardSource.test.tsx` → FAIL. + +- [ ] **Step 3: Реализация** — `web/src/board/useBoardSource.ts`: + +```ts +// источник данных доски: снимок хаба (GET /board) или файл (CSV/JSON); обновление из Jira — пакет refresh от имени +// viewer и опрос его хода. Устройство закрывает недочёты jira-hub#20: явный источник (M-1), одна кнопка и один опрос +// (M-2), подхват незавершённого пакета после любой загрузки (M-3), остановка опроса (M-4), одно событие состояния (M-5) +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { api, ApiError, AuthError, HubDownError } from "../api/client.ts"; +import { qk, useBoard } from "../api/queries.ts"; +import type { AgentView, Batch, CreatedBatch } from "../api/types.ts"; +import { clearToken } from "../auth/token.ts"; +import { fromCsv } from "./model/fromCsv.ts"; +import { fromHub } from "./model/fromHub.ts"; +import { fromJson } from "./model/fromJson.ts"; +import { emptyModel, fmtStamp } from "./model/util.ts"; +import { MAX_POLL_ERRORS, POLL_MS, type BoardStatus } from "./status.ts"; +import type { BoardModel, Dicts, JsonImport } from "./types.ts"; + +type Source = { kind: "hub" } | { kind: "file"; model: BoardModel }; +interface Active { id: number; agentOffline: boolean } + +export interface BoardSource { + model: BoardModel; + source: "hub" | "file"; + label: string; + title: string; + status: BoardStatus | null; + refreshing: boolean; + reload(): void; + refreshFromJira(): Promise; + 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({ kind: "hub" }); + const [event, setEvent] = useState(null); + const [active, setActive] = useState(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(`/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("/agent"); + const b = await api.post("/refresh", { full: true, author: "viewer" }); + failures.current = 0; + setActive({ id: b.id, agentOffline: !agent.online }); + setEvent({ kind: "batch", id: b.id, phase: "queued", remaining: b.estimate_seconds, agentOffline: !agent.online, retrying: false }); + } catch (e) { + report(e, !!board.data); + } finally { + startingRef.current = false; + setStarting(false); + } + }, [active, report, board.data]); + + const reload = useCallback(() => { + setSource({ kind: "hub" }); + setEvent(null); + void qc.refetchQueries({ queryKey: qk.board }); + }, [qc]); + + const loadFile = useCallback((fileName: string, text: string): JsonImport | null => { + if (!dicts) return null; + const isJson = /\.json$/i.test(fileName) || /^?\s*\{/.test(text); + try { + if (!isJson) { + setSource({ kind: "file", model: fromCsv(text, fileName, dicts) }); + setEvent(null); + return null; + } + const r = fromJson(text, fileName, dicts); + if (board.data) { // режим хаба: из файла — раскладка и фильтры, данные — из хаба + setSource({ kind: "hub" }); + setEvent({ kind: "json-applied", fileName }); + void qc.refetchQueries({ queryKey: qk.board }); + } else { // хаб не ответил — снимок из файла + setSource({ kind: "file", model: r.model }); + setEvent({ kind: "hub-down", hasData: true }); + } + return r; + } catch (e) { + setEvent({ kind: "file-error", fileName, reason: e instanceof Error ? e.message : String(e) }); + return null; + } + }, [dicts, board.data, qc]); + + const ls = board.data?.last_sync ?? null; + const model = source.kind === "file" ? source.model : hubModel ?? emptyModel(board.isPending + ? "Загрузка данных хаба…" + : "Нет данных: хаб не отвечает. Запустите хаб (jira-hub serve) и нажмите «Перечитать» или загрузите файл CSV из фильтра Jira."); + const label = source.kind === "file" ? `${source.model.fileName} (из файла)` + : board.data ? (ls ? `Хаб · данные на ${fmtStamp(ls.finished_at)}` : "Хаб · полного обновления не было") + : board.isPending ? "Хаб" : "Хаб недоступен"; + const title = source.kind === "file" ? `${source.model.fileName} — снимок из файла; «Перечитать» вернёт данные хаба` + : ls && board.data ? `Последнее полное обновление из Jira: ${fmtStamp(ls.started_at)} – ${fmtStamp(ls.finished_at)}, задач: ${ls.issues_count}. Снимок хаба: ${fmtStamp(board.data.generated_at)}` + : label; + const status: BoardStatus | null = event ?? (source.kind === "hub" && board.isPending ? { kind: "loading" } : null); + + return { model, source: source.kind, label, title, status, refreshing: starting || active !== null, reload, refreshFromJira, loadFile }; +} +``` + +- [ ] **Step 4: Прогнать** + +Run: `cd web && npm run lint && npx vitest run src/board` +Expected: PASS — 11 тестов хука, 2 подписей, прежние. Если тест M-4 с пятью ошибками нестабилен (лишний запрос из-за +`refetchInterval`), проверить, что счётчик `failures` увеличивается в `queryFn` до вычисления `refetchInterval`, а не в +эффекте; таймауты тестов не увеличивать. + +- [ ] **Step 5: Commit** + +```bash +git add web/src/board/useBoardSource.ts web/src/board/status.ts web/src/board/*.test.ts* +git commit -m "feat(web): источник данных доски и «Обновить из Jira» — закрывает M-1…M-5 #20 (#21) + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- +### Task 11: доска — холст: дорожки, карточки, стрелки, перетаскивание, колонки дат + +**Модель:** sonnet (React + pointer-события; поведение сверяется со строками `links.html`). + +**Files:** +- Create: `web/src/board/components/BoardCanvas.tsx`, `web/src/board/components/Card.tsx`, + `web/src/board/components/Lane.tsx`, `web/src/board/components/Edges.tsx`, `web/src/board/components/DatesBar.tsx`, + `web/src/board/board.css` +- Test: `web/src/board/components/BoardCanvas.test.tsx` + +**Interfaces:** +- Consumes: `computeLayout`, `place`, `measuredHeight`, `clampToCol`, `lineClamp`, `dateText`, `dateTitle` (Task 8), + `edgePath`, `alignEdges` (Task 8), `saveLayout` (Task 9), константы (Task 6). +- Produces: + +```ts +export interface LayoutStore { + laneOrder: string[]; folded: Set; colWidths: Record; + version: number; // растёт при каждой записи раскладки в хранилище (сброс, импорт JSON) — повод пересчитать раскладку +} +export interface BoardCanvasProps { + vis: Visible; byDate: boolean; snap: boolean; today: string; store: LayoutStore; sprintSel: Set; + emptyText: string; // текст пустой доски (emptyReason) + onToggleLane(name: string): void; + onMoveLane(name: string, to: number): void; // to — новый индекс среди vis.lanes + onColWidth(date: string, w: number | null): void; // null — вернуть автоматическую ширину + children?: ReactNode; // боковая панель внутри .stage (ящик «Проблемы») +} +export function BoardCanvas(p: BoardCanvasProps): JSX.Element; +export const Card: React.MemoExoticComponent<(p: CardProps) => JSX.Element>; +``` + +Разметка и классы — как у `links.html` (строки 296–302 и функции `renderLane`, `renderCard`, `renderDateCols`, +`drawEdges`), чтобы стили переносились без изменений. Новое — только значок «ⓘ» (решение R-1). + +- [ ] **Step 1: Стили** — `web/src/board/board.css` ← CSS ревизии 3931a88: из раздела 3 (строки 42–51) всё, кроме +`.link…` (они в `controls.css`); разделы 5–9 (строки 92–237) целиком. Добавить в конец: + +```css +/* «Открыть карточку» в заголовке карточки (R-1) */ +.card .open-card{margin-left:6px;color:var(--muted);text-decoration:none;font-size:13px} +.card .open-card:hover,.card .open-card:focus-visible{color:var(--accent);outline:none} +/* слой измерения: карточки шириной CW с высотой по содержимому, невидим */ +.measure{position:absolute;left:-100000px;top:0;visibility:hidden;pointer-events:none} +.measure .card{position:static} +.measure .card .title{-webkit-line-clamp:unset} +``` + +- [ ] **Step 2: Падающий тест холста** — `web/src/board/components/BoardCanvas.test.tsx`: + +```tsx +import { fireEvent, screen, within } from "@testing-library/react"; +import { readFileSync } from "node:fs"; +import { describe, expect, it, vi } from "vitest"; +import type { BoardSnapshot } from "../../api/types.ts"; +import { renderWithProviders } from "../../test/render.tsx"; +import { computeVisible } from "../analysis/visible.ts"; +import { dictsFromSettings } from "../dicts.ts"; +import settings from "../__fixtures__/settings.json"; +import { fromHub } from "../model/fromHub.ts"; +import { noFilters } from "../types.ts"; +import { BoardCanvas, type LayoutStore } from "./BoardCanvas.tsx"; + +const fx = (name: string) => readFileSync(new URL(`../__fixtures__/${name}`, import.meta.url), "utf8"); +const { dicts } = dictsFromSettings(settings); +const model = fromHub(JSON.parse(fx("hub-basic.board.json")) as BoardSnapshot, dicts); +const store: LayoutStore = { laneOrder: [], folded: new Set(), colWidths: {}, version: 0 }; + +function draw(over: Partial[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(), emptyText: "пусто", + onToggleLane: vi.fn(), onMoveLane: vi.fn(), onColWidth: vi.fn(), ...over }; + renderWithProviders(); + return props; +} +const board = () => document.querySelector(".board") as HTMLElement; + +describe("BoardCanvas", () => { + it("дорожки по эпикам и карточки задач с заглушкой", () => { + draw(); + const lanes = [...board().querySelectorAll(".lane-name")].map((e) => e.textContent); + expect(lanes).toEqual(["AURORA-9999", "Гудвилл", "Тестовый эпик", "Без эпика", "Нет в зеркале"]); + expect(board().querySelectorAll(".card[data-key]")).toHaveLength(10); + expect(within(board()).getByText("нет в зеркале")).toBeInTheDocument(); + }); + + it("ключ ведёт в Jira, ⓘ — в карточку приложения; у заглушки ⓘ нет", () => { + draw(); + const card = board().querySelector('.card[data-key="AURORA-9001"]') as HTMLElement; + expect(within(card).getByRole("link", { name: "AURORA-9001" })).toHaveAttribute("href", "https://sdlc.go.rshbank.ru/jira/browse/AURORA-9001"); + expect(within(card).getByRole("link", { name: "Открыть карточку AURORA-9001" })).toHaveAttribute("href", "/issues/AURORA-9001"); + const stub = board().querySelector('.card[data-key="AURORA-9500"]') as HTMLElement; + expect(within(stub).queryByRole("link", { name: /Открыть карточку/ })).toBeNull(); + }); + + it("стрелки: по одной на видимую связь, «связанные» пунктиром, блокирующие со стрелкой", () => { + draw(); + const paths = [...document.querySelectorAll("svg.edges path")]; + expect(paths).toHaveLength(7); + expect(paths.filter((p) => p.getAttribute("stroke-dasharray") === "5 4")).toHaveLength(1); + expect(paths.filter((p) => p.getAttribute("marker-end"))).toHaveLength(6); + }); + + it("свёртка и перестановка дорожек — через колбэки", () => { + const p = draw(); + const lane = [...board().querySelectorAll(".lane")].find((l) => l.textContent?.includes("Гудвилл")) as HTMLElement; + fireEvent.click(within(lane).getByTitle("Свернуть или развернуть дорожку")); + expect(p.onToggleLane).toHaveBeenCalledWith("Гудвилл"); + fireEvent.click(within(lane).getByTitle("Ниже")); + expect(p.onMoveLane).toHaveBeenCalledWith("Гудвилл", 2); + }); + + it("свёрнутая дорожка прячет свои карточки", () => { + draw({ store: { ...store, folded: new Set(["Гудвилл"]) } }); + expect(board().querySelector('.card[data-key="AURORA-9004"]')).toBeNull(); + expect(board().querySelector(".lane.folded")).not.toBeNull(); + }); + + it("«по датам»: колонки и подписи дат, двойной щелчок по границе — автоматическая ширина", () => { + const p = draw({ byDate: true }); + expect(board().querySelectorAll(".dcol").length).toBeGreaterThan(0); + expect(screen.getByText("16.10")).toBeInTheDocument(); + fireEvent.doubleClick(document.querySelector(".dsplit") as HTMLElement); + expect(p.onColWidth).toHaveBeenCalledWith(expect.any(String), null); + }); + + it("пустая выборка — текст причины", () => { + draw({ vis: computeVisible(model, { ...noFilters(), who: new Set(["Никто"]) }, [], "2026-10-14"), emptyText: "Фильтры не оставили" }); + expect(screen.getByText("Фильтры не оставили")).toBeInTheDocument(); + }); +}); +``` + +Run: `cd web && npx vitest run src/board/components` → FAIL. + +- [ ] **Step 3: `Card.tsx`** (разметка строк 734–751): + +```tsx +import { memo, type PointerEvent } from "react"; +import { Link } from "react-router"; +import { CW, JIRA_URL, KIND, PRIO } from "../constants.ts"; +import { lineClamp, type Rect, type RelBox } from "../layout/layout.ts"; +import { fmtDate, shortName, unassigned } from "../model/util.ts"; +import type { VisNode } from "../types.ts"; + +export interface CardProps { + node: VisNode; + box?: Rect; // нет — карточка слоя измерения (ширина CW, высота по содержимому) + rel?: RelBox; + multiSprint: boolean; + sprintSel: Set; + onPointerDown?: (ev: PointerEvent, 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 ( +
onPointerDown(e, n.key) : undefined}> +
+ {n.key} + {!n.stub && ⓘ} + {showTag && (n.sprint + ? {n.sprint.replace(/^AWPCA_/, "")} + : без спринта)} +
+
{n.title}
+
+ {n.kind} + {n.prio} +
+ {(n.status || n.due) && ( +
+ {n.status && {n.status}} + {n.due && до {fmtDate(n.due)}} +
+ )} + {!n.stub &&
{shortName(n.who)}
} +
onAutoSize(n.key) : undefined} /> +
+
+ ); +}); +``` + +- [ ] **Step 4: `Edges.tsx`** (строки 330–331 `ARROWS`, 838–849 `drawEdges`, 860–863 направляющие): + +```tsx +import type { Placed } from "../layout/layout.ts"; +import { edgePath } from "../layout/edges.ts"; +import { RELATED } from "../constants.ts"; +import type { VisLink } from "../types.ts"; + +export function Edges({ links, placed }: { links: VisLink[]; placed: Placed }) { + return ( + + + + + + + + + + {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 ${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)"} />; + })} + + ); +} + +export function Guides({ x, y, width, height }: { x: number | null; y: number | null; width: number; height: number }) { + return ( + + {x !== null && } + {y !== null && } + + ); +} +``` + +- [ ] **Step 5: `Lane.tsx`** (строки 715–733): + +```tsx +import { useRef, useState, type PointerEvent } from "react"; +import type { LaneGeom } from "../layout/layout.ts"; +import { plural } from "../model/util.ts"; + +interface LaneProps { + g: LaneGeom; index: number; last: number; count: number; + lanes: LaneGeom[]; // все дорожки — для выбора места при перетаскивании + boardTop: () => number; // верх доски в координатах окна + targetClass: string; // "target" / "target-after" — подсветка целевой границы (строка 729) + onToggle(name: string): void; + onMove(name: string, to: number): void; + onDragTarget(to: number | null): void; // цель перетаскивания этой дорожки; null — перетаскивание закончено +} + +export function Lane({ g, index, last, count, lanes, boardTop, targetClass, onToggle, onMove, onDragTarget }: LaneProps) { + const [dragging, setDragging] = useState(false); + const nameRef = useRef(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) => { + 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 ( +
+
+ + + +
+
+ ); +} +``` + +- [ ] **Step 6: `DatesBar.tsx`** (строки 678–703, 705–714): + +```tsx +import { Fragment, useRef, type PointerEvent, type RefObject } from "react"; +import { COLW, GRID } from "../constants.ts"; +import { dateText, dateTitle, type DateCol } from "../layout/layout.ts"; + +// липкая полоса подписей дат над доской: сдвиг — как у горизонтальной прокрутки доски; ручка границы меняет ширину колонки +export function DatesBar({ cols, width, scrollLeft, inRef, onColWidth }: { + cols: DateCol[]; width: number; scrollLeft: number; inRef: RefObject; + onColWidth(date: string, w: number | null): void; +}) { + const raf = useRef(0); + const down = (ev: PointerEvent, 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 ( +
+
+
+ {cols.map((c) => ( + + {dateText(c)}{c.count} +
down(e, c)} onDoubleClick={() => onColWidth(c.date, null)} /> + + ))} +
+
+
+ ); +} +``` + +- [ ] **Step 7: `BoardCanvas.tsx`** + +```tsx +import { useCallback, useLayoutEffect, useMemo, useRef, useState, type PointerEvent, type ReactNode } from "react"; +import { CW, GRID, MINH, MINW, TOP } from "../constants.ts"; +import { alignEdges } from "../layout/align.ts"; +import { clampToCol, computeLayout, measuredHeight, place, type Layout, type Placed, type RelBox } from "../layout/layout.ts"; +import { saveLayout, savedLayout } from "../storage.ts"; +import type { Visible } from "../types.ts"; +import { Card } from "./Card.tsx"; +import { DatesBar } from "./DatesBar.tsx"; +import { Edges, Guides } from "./Edges.tsx"; +import { Lane } from "./Lane.tsx"; +import "../board.css"; + +export interface LayoutStore { laneOrder: string[]; folded: Set; colWidths: Record; version: number } +export interface BoardCanvasProps { + vis: Visible; byDate: boolean; snap: boolean; today: string; store: LayoutStore; sprintSel: Set; + 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(null); + const measureRef = useRef(null); + const wrapRef = useRef(null); + const hscrollRef = useRef(null); + const datesInRef = useRef(null); + const [layout, setLayout] = useState(null); + const [rel, setRel] = useState>({}); + 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(); + measureRef.current?.querySelectorAll(".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(".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(() => (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, 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(`.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 && ( + + )} +
+
+
+ {empty &&
{emptyText}
} + {placed && !empty && ( + <> + {layout?.dateCols?.map((c) => ( +
+ ))} + {placed.lanes.map((g, i) => ( + dragTarget.from ? "target-after" : "") : ""} + onDragTarget={(to) => setDragTarget(to === null ? null : { from: i, to })} /> + ))} + {Object.entries(placed.pos).map(([k, box]) => ( + + ))} + + + + )} +
+
+ {children} +
+