# 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}