## Context Хаб (`main` 3931a88) отдаёт по `GET /` и `GET /links` страницу доски связей `src/jira_hub/web/links.html` (~1200 строк, из них ~900 — JS без зависимостей). Разделы скрипта: справочники и константы (`EPICS`, `KIND_LABELS`, `KIND`, `PRIO`, `LINK_TYPES`, геометрия), состояние и хранилище (`MODEL`, фильтры `S`, выборка `VIS`, раскладка `rel/pos`, `localStorage` `graph-<имя>…` с `migrateStore`), разбор (`fromJiraCsv`, `fromJson`, `fromHub`), выборка и анализ (`computeVisible`, `criticalPath`, `findProblems`), раскладка и отрисовка (`render`, `relayout`, `renderLane`, `renderCard`, `startDrag`, `alignEdges`, `drawEdges`, `orthoPath`, колонки дат), панель (`makeMulti`, легенда, проблемы, таблица), хаб (`hubFetch`, `loadHub`, `refreshFromJira`, `watchBatch`, `resumeWatch`), экспорт/импорт JSON (`arm-ka-links/1`). Финальное ревью `viewer-live` отложило M-1…M-7 (jira-hub#20): гонки поздних ответов и опроса, бесконечный опрос, перезаписываемые сообщения, форма входа, непокрытая ветка `mirror.board(own=False)`. API хаба для интерфейсов уже есть и не меняется: `GET /board` (сырой снимок зеркала), `GET /issues` (фильтры `sprint`, `assignee`, `work_kind`, `fact_status`, `status`, `label`, `epic`, `neighbor`, `q`; `limit`/`offset`, без общего количества), `GET /issues/{key}` (поля, `links_out`, `links_in`, `note`, 10 последних `commands`), `PUT /issues/{key}/note` (поля `fact_status`, `work_kind`, `done_text`, `rest_text`, `owner_note`; 422 на неизвестные), `GET /settings` (без токенов; `kind_labels`, `link_types`, `status_labels`), `GET /agent`, `POST /refresh`, `GET /batches/{id}`. HTTP — `http.server`, маршруты `ROUTES` с областью токена; не-JSON ответ — `Raw(content_type, bytes)`. На Mac mini стоят Node 24 и npm 11; хаб работает под launchd (`uv run --project <основной checkout> jira-hub serve`). ## Goals / Non-Goals **Goals:** веб-приложение хаба, в которое доска перенесена без потери поведения и раскладок; страница задач зеркала с карточкой и правкой заметок; закрыть #20; код фронта, который владелец может читать, а агент — развивать (`viewer-edit`). **Non-Goals:** запись в Jira из интерфейса, страницы очереди/агента/настроек, изменения API, изменения логики доски (кроме «Открыть карточку»), доступ из сети. ## Decisions 1. **Отдельный npm-проект `web/` в корне репозитория.** `web/package.json`, `web/vite.config.ts`, `web/tsconfig.json`, исходники `web/src/`, тесты рядом с кодом (`*.test.ts(x)`), фикстуры `web/src/**/__fixtures__/`. Python-пакет не меняется. Менеджер пакетов — npm, `package-lock.json` в git, установка `npm ci`. 2. **Стек.** React 19, TypeScript (strict), Vite, React Router (`createBrowserRouter`, `basename: "/app"`), TanStack Query (загрузка, кэш, опрос), Vitest + Testing Library (jsdom), ESLint (typescript-eslint, react-hooks), Playwright для e2e. Стили — обычный CSS: переменные и классы `links.html` переносятся в `web/src/styles/`, у страниц — свои файлы CSS. UI-кита и библиотеки графов нет. Версии в `package.json` — точные, без `^`. 3. **Приложение под `/app/`.** Пути API (`/issues`, `/board`, …) заняты, от них зависят агент и MCP; префикс разводит адреса приложения и API без изменения API. Vite: `base: "/app/"`, `build.outDir: "../src/jira_hub/web/app"`, `emptyOutDir: true`. Маршруты приложения: `/app/board`, `/app/issues`, `/app/issues/:key`; `/app` и `/app/` → `/app/board`; неизвестный путь внутри приложения — страница «не найдено» со ссылками на разделы. 4. **Раздача в хабе** (`api.py`, без области токена — в сборке нет данных): - `GET /app/assets/<путь>` — файл из `src/jira_hub/web/app/assets/`; путь проверяется: после `resolve()` он SHALL лежать внутри каталога `assets`, иначе 404; `Content-Type` по расширению (`.js` → `text/javascript; charset=utf-8`, `.css` → `text/css; charset=utf-8`, `.svg` → `image/svg+xml`, `.png`, `.ico`, `.woff2`, `.map` → `application/json`; прочее — `application/octet-stream`); `Cache-Control: public, max-age=31536000, immutable` (имена файлов содержат хэш); - прочие `GET /app`, `GET /app/…` — `index.html` сборки, `text/html; charset=utf-8`, `Cache-Control: no-cache`; - нет `index.html` (сборка не выполнена) — 503 `text/html` с текстом «Веб-приложение не собрано: выполните `make web` в каталоге jira-hub и перезапустите хаб» — на любой `/app…`; - `GET /` и `GET /links` — 302 `Location: /app/board`; - `PAGE` и `links.html` удаляются. Файлы читаются с диска на каждый запрос (как сейчас `links.html`): новая сборка видна без перезапуска. 5. **Сборка не в git, выкатка с `make web`.** `src/jira_hub/web/app/` — в `.gitignore`. Новый `Makefile` в корне: `make web` = `cd web && npm ci && npm run build`; `make check` = `uv run pytest` + `cd web && npm run lint && npm test`. Выкатка: `git pull` → `make web` → `launchctl kickstart -k gui/$(id -u)/ru.aepif.jira-hub`. Pytest не зависит от сборки: тесты раздачи подкладывают свой каталог сборки (параметр `Hub`/путь, подменяемый в тесте). 6. **Разработка.** `npm run dev` — Vite на `127.0.0.1:5173` с `base: "/app/"` и прокси всех путей API на хаб (`JIRA_HUB_URL`, по умолчанию `http://127.0.0.1:8791` — тестовый хаб); прокси перечисляет префиксы API явно (`/board`, `/issues`, `/settings`, `/agent`, `/refresh`, `/batches`, `/commands`, `/sync-runs`). 7. **Один клиент API `web/src/api/client.ts`.** `fetch` того же хоста с `Authorization: Bearer <токен>`, `Accept: application/json`, `cache: "no-store"`. Результат — данные или типизированная ошибка: `AuthError` (401), `HubDownError` (сеть или 5xx/502/503), `ApiError {status, error, detail}` (прочие 4xx). Типы ответов — `web/src/api/types.ts` по спеке hub-api. Запросы — хуки TanStack Query в `web/src/api/queries.ts` (`useBoard`, `useSettings`, `useIssues`, `useIssue`, `useAgent`, `useBatch`) и мутации (`useRefresh`, `useSaveNote`). 8. **Вход.** Токен — `localStorage["jira-hub-token"]` (тот же ключ, что у `links.html`) и копия в памяти на случай закрытого хранилища. Нет токена или любой запрос ответил `AuthError` → экран входа поверх приложения: панели и страницы не показываются, запросы не идут (`enabled: false`), поле токена не трогается фоновыми событиями (M-6). После ввода — проверка `GET /settings`; при успехе токен сохраняется, кэш запросов сбрасывается. «Сменить токен» в оболочке очищает токен и показывает экран входа. Загрузки файла CSV/JSON с экрана входа, как у `links.html`, нет: разбору нужны словари из `/settings`, а приложение отдаёт сам хаб, так что без хаба его не открыть; файл загружается на доске после входа. 9. **Оболочка.** Верхняя панель: название «Хаб Jira», ссылки «Доска», «Задачи», «Сменить токен». Граница ошибок (`ErrorBoundary`) на уровне страницы: неожиданное исключение отрисовки → сообщение и кнопка «Перезагрузить», панель остаётся. 10. **Доска: модули без React** (`web/src/board/`): - `constants.ts` — `EPICS` (запасной словарь имён эпиков для CSV), `KIND`, `PRIO`, `JIRA_URL`, `RELATED`, служебные дорожки, геометрия; `KIND_LABELS` и `LINK_TYPES` в коде не задаются — приходят параметром `dicts` из `/settings` (`kind_labels`, `link_types`; у `link_types` — обратный словарь «имя Jira → логический тип»); - `model/` — `fromHub(snap, dicts)`, `fromCsv(text, fileName, dicts)`, `fromJson(text, fileName)` → `BoardModel`; `util.ts` — `sortableDate`, `shortName`, `plural`, `fmtDate`, `notStarted`, `unassigned`; - `analysis/` — `computeVisible(model, filters)`, `criticalPath`, `findProblems`, `emptyReason`; - `layout/` — раскладка дорожек и карточек (`relayout`, `autoHeight` через переданную функцию измерения, `clampToCol`), колонки дат, `alignEdges`, `orthoPath`, `edgePath`; - `storage.ts` — ключи `graph-<имя>…`, `migrateStore`, чтение/запись раскладки, дорожек, фильтров, темы; все обращения к `localStorage` в `try/catch`, как сейчас. Алгоритмы переносятся построчно; переименование и упрощение — только без изменения результата на golden-фикстурах. 11. **Доска: состояние и компоненты.** Модель и источник — в `useBoardSource` (раздел 12); фильтры, раскладка и переключатели — `useReducer` в `BoardPage` с сохранением в `storage.ts`. Компоненты: `Toolbar` (источник, «Перечитать», «Обновить из Jira», «файл…», статистика, «Проблемы», меню «⋯» без «Сменить токен»), `FilterBar` + `MultiSelect`, `Legend`, `Board` (контейнер с двумя SVG: `Edges`, `Guides`), `Lane`, `Card`, `DatesBar`, `ProblemsDrawer`, `LinkTable`, `ExportBox`, `StatusLine`. Перетаскивание и растяжка — pointer-события на `Card`, во время движения положение пишется в состояние раскладки, сохранение — по `pointerup` (как сейчас). Размер карточки «по содержимому» измеряется через `ref` на DOM карточки. 12. **Доска: источник данных и обновление (закрывает M-1…M-5).** `useBoardSource` хранит явный источник: `{kind: "hub"}` или `{kind: "file", model, fileName}`. Данные хаба (`useBoard`) показываются только при источнике `hub`; загрузка CSV/JSON-снимка переключает источник на `file`, поздний ответ хаба его не затирает (M-1); «Перечитать» возвращает источник `hub` и перезапрашивает `/board`. Импорт JSON в режиме хаба применяет раскладку, дорожки и фильтры и перечитывает хаб (как сейчас). «Обновить из Jira»: `GET /agent` (офлайн → предупреждение, пакет всё равно ставится) → мутация `POST /refresh {full: true, author: "viewer"}` → `useBatch(id)` с `refetchInterval` 3 с до терминального статуса пакета → `invalidate(/board)`. Кнопка выключена, пока мутация в пути или есть активный пакет (M-2); опрос пакета — один запрос по ключу `["batch", id]`, второго опроса не бывает. Незавершённый пакет `viewer` ищется (`GET /batches`) при каждой успешной загрузке `/board`, а не только при первой (M-3). Опрос останавливается на 404 сразу и после 5 ошибок `HubDownError` подряд — с сообщением в строке состояния и снова включённой кнопкой (M-4). `StatusLine` показывает одно состояние из перечня: «загрузка», «данные хаба на <время>», «из файла <имя>», «пакет #N выполняется», «пакет #N выполнен», «пакет #N: ошибка <текст>», «хаб не отвечает — показаны прежние данные», «агент офлайн — пакет поставлен»; ошибки разбора файла — отдельным текстом «файл не разобран: <причина>», не «хаб не отвечает» (M-5). 13. **Доска: «Открыть карточку».** В заголовке карточки (не у заглушек) рядом с ключом — значок-ссылка «ⓘ» на `/app/issues/{key}` (переход внутри приложения). Ключ по-прежнему открывает задачу в Jira, перетаскивание и выделение текста не меняются. 14. **Задачи: список `/app/issues`.** Таблица: ключ, тема, тип, статус Jira, исполнитель, спринт, срок (`planned_end`, иначе `duedate`), эпик (имя из снимка `/board`, иначе ключ), вид работы, статус по факту, «сосед». Фильтры — параметры `GET /issues`, состояние фильтров — в query-строке адреса (`useSearchParams`); поиск `q` применяется с задержкой 300 мс. Варианты выпадающих списков (спринты, исполнители, статусы Jira, эпики, метки, виды работы, статусы по факту) — из `useBoard()`, без новых маршрутов. Порции по 50 (`useInfiniteQuery`, `offset`), кнопка «Показать ещё» видна, пока последняя порция полная (50 строк). Ключ — ссылка на карточку. 15. **Задачи: карточка `/app/issues/:key`.** Поля задачи, «открыть в Jira», описание — текст с `white-space: pre-wrap` без разбора разметки Jira; связи «исходящие»/«входящие» с подписью типа (логический тип из `link_types`, иначе имя Jira), ключ — ссылка на карточку; 10 последних команд: id, пакет, тип, статус, время, ошибка. 404 → «задачи нет в зеркале» со ссылкой на список. 16. **Задачи: заметки.** Форма в карточке: «вид работы» — выпадающий список из значений `kind_labels` (плюс текущее значение, если его там нет, и «—» = пусто); «статус по факту» — поле с подсказками (`datalist`) из ключей `status_labels`; «сделано», «осталось», «заметка владельца» — многострочные поля. «Сохранить» активна при изменениях и отправляет только изменённые поля (`PUT /issues/{key}/note`, `updated_by: "web"`); пустое поле отправляется как `null`. Успех → обновлённая заметка в кэше карточки и списка, «сохранено <время>, <кто>». 422 → текст `detail` у формы, введённое не теряется. Уход со страницы с несохранёнными правками → подтверждение (`useBlocker` и `beforeunload`). 17. **Golden-эталоны доски из старой страницы.** Скрипт `web/scripts/golden-from-links.mjs` (разовый, в git): читает `links.html` из git-ревизии 3931a88, исполняет его `