Andrey Epifancev b421687188 docs(openspec): web-app — рулинг R-14 (#21)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 22:24:42 +04:00

30 KiB
Raw Blame History

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, исполняет его <script> в node:vm с минимальной заглушкой DOM, вызывает fromHub/fromJiraCsv/fromJson, computeVisible при наборах фильтров, criticalPath, findProblems, orthoPath и пишет <name>.expected.json рядом с фикстурами. Фикстуры — синтетические (<name>.board.json, <name>.csv, <name>.layout.json): эпики, соседи, заглушки «нет в зеркале», все три типа связей и неизвестный тип, несколько спринтов, задачи без исполнителя и срока, передачи между людьми, цепочка для критического пути. Тесты Vitest сравнивают выход TS-модулей с эталоном побайтно после JSON.stringify с сортировкой ключей.
  18. Проверки. Vitest: golden-модули доски, client.ts (401/сеть/4xx), storage.ts (ключи, миграция, закрытое хранилище), useBoardSource (M-1…M-5 сценариями с подменённым fetch и таймерами), «Задачи» (фильтры в адресе, «Показать ещё», заметка шлёт только изменённое, 422, подтверждение ухода). Pytest: раздача /app… (типы, кэш, index.html на произвольный путь, 503 без сборки, обход каталога → 404, редиректы ///links, без токена), тест mirror.board внутри открытой транзакции (M-7). Playwright e2e на тестовом хабе :8791 (JIRA_HUB_HOME во временном каталоге, зеркало заполнено POST /agent/state из golden-фикстуры): вход и неверный токен, доска (перетаскивание, растяжка, стрелки, «по датам», «Обновить из Jira» до конца пакета с учебным агентом, «Открыть карточку»), задачи (фильтр → карточка → заметка). До удаления links.html — скриншоты старой и новой доски на одной фикстуре и одной раскладке, расхождения разбираются вручную. tsc --noEmit и ESLint — в npm run lint.

Risks / Trade-offs

  • [Переписывание ~900 строк может изменить поведение доски незаметно] → golden-эталоны старой страницы на модулях логики, e2e и сверка скриншотов на отрисовке; перенос построчный, улучшения — только без изменения результата.
  • [Перетаскивание через состояние React может тормозить на ~200 карточках] → перерисовывается только двигаемая карточка и стрелки (memo на Card, раскладка — в отдельном контексте); если e2e покажет задержку — положение во время движения через ref/transform, в состояние — по pointerup.
  • [Сборка не в git: после git pull без make web хаб отдаёт старую сборку или 503] → 503 с явной подсказкой; шаг выкатки в README и Migration Plan.
  • [Зависимости npm — поверхность цепочки поставок] → точные версии, package-lock.json, npm ci; зависимостей времени выполнения четыре (react, react-dom, react-router, @tanstack/react-query).
  • [Словари из /settings пусты на чистой базе] → доска без link_types не покажет связей; экран показывает «в настройках хаба нет link_types/kind_labels» вместо пустой доски.
  • [Токен в localStorage доступен скриптам страницы] → сторонних скриптов нет, сборка своя; доступ только с Mac mini.

Migration Plan

  1. Merge → в основном checkout git pull → make web → launchctl kickstart -k gui/$(id -u)/ru.aepif.jira-hub.
  2. Открыть http://127.0.0.1:8790/ → редирект на /app/board; вход уже есть (тот же ключ токена), раскладка — та же (те же ключи graph-…).
  3. Проверить: доска совпадает с прежней, «Обновить из Jira» проходит, «Задачи» → карточка → заметка сохраняется.
  4. Откат: предыдущий main и перезапуск — / снова отдаёт links.html; раскладки в localStorage не меняются новым приложением по формату, старая страница их читает.

Open Questions

Нет.

Decisions made without discussion

Маркер Дата Решение Обоснование Цена ошибки
R-1 (design #13) 2026-10-14 Ответ владельца «да» на вопрос «добавить переход с карточки доски в карточку задачи или оставить на viewer-edit» понят как «добавить»; переход — отдельный значок «ⓘ», щелчок по ключу по-прежнему открывает Jira вопрос и одобрение раздела пришли одним «да»; значок не меняет существующее поведение доски убрать значок — одна правка компонента
R-2 (design #1) 2026-10-14 Менеджер пакетов — npm с package-lock.json и npm ci npm уже стоит на Mac mini, ничего доставлять не нужно переход на pnpm — замена lock-файла и команд в Makefile
R-3 (design #4) 2026-10-14 Файлы assets кэшируются навсегда (immutable), index.html — no-cache имена файлов сборки содержат хэш; новая сборка видна без перезапуска и без сброса кэша при ошибке — старый интерфейс до сброса кэша браузера
R-4 (design #17) 2026-10-14 Фикстуры доски синтетические, не снимок живого зеркала в зеркале данные Jira банка, в личном репозитории их держать не надо синтетика может не покрыть редкий случай живых данных — его ловит сверка на живом хабе при выкатке
R-5 (design #6) 2026-10-14 Прокси разработки перечисляет префиксы API явно, по умолчанию на тестовый хаб :8791 разработка не трогает живой хаб; явный список не перехватывает пути приложения новый маршрут API надо дописать в прокси
R-6 (design #16) 2026-10-14 updated_by заметок из приложения — "web"; пустое поле отправляется как null у хаба один клиентский токен, пользователя он не различает; null очищает поле так же, как MCP автор в истории заметок неинформативен — до появления пользователей
R-7 (design #14) 2026-10-14 Поиск q в списке задач — с задержкой 300 мс без задержки запрос на каждую букву неудобство ввода — меняется одна константа
R-8 (design #4) 2026-10-14 Каталог сборки читается с диска на каждый запрос, без кэша в памяти хаба так же работает links.html; make web без перезапуска хаба лишнее чтение файла на запрос — несущественно при одном пользователе
R-9 (design #8) 2026-10-14 Экран входа не предлагает загрузку CSV/JSON без хаба; файл — только на доске после входа разбор файла требует словарей из /settings; приложение отдаёт хаб, сценария «без хаба» нет посмотреть CSV без токена нельзя — можно кэшировать словари в localStorage позже
R-10 (design #11) 2026-10-14 Двойной щелчок по уголку карточки меряет высоту по содержимому при новой ширине (CW в пределах колонки), а не при прежней, как links.html (побочный эффект порядка вызовов в строке 750) намерение «размер по содержимому» — высота под ширину, которую ставит то же действие; golden-тесты этот путь не покрывают на карточке с ручной шириной высота после двойного щелчка может отличаться от старой страницы на строку заголовка
R-11 (design #12) 2026-10-14 «Строка состояния» доски — два элемента, как у links.html: подпись источника (label: «Хаб · данные на …», «… (из файла)», на месте fileName) и одно текущее событие (status: загрузка, пакет, ошибки — на месте hubState); событие пишет один автомат состояний, перезаписи сообщений (M-5) нет так выглядела прежняя доска, а M-5 — про гонку сообщений, не про число элементов; формулировка требования «Источник данных доски» уточнена если нужна буквально одна строка — объединить два элемента в Toolbar
R-12 (design #18) 2026-10-14 Строка Co-Authored-By в коммитах исполнения — имя модели реального автора (haiku/sonnet), а не «Claude Opus 5.5» из плана; амендов нет коммиты делают субагенты haiku/sonnet, системная инструкция атрибуции требует их имя неверная подпись в трейлере коммитов. Пересмотрено основной сессией: с Task 5 трейлер — по плану (Opus 5.5); коммиты Tasks 1–4 не переписываются
R-13 (design #19) 2026-10-14 fromJson сам нормализует копию фильтров: при mode === "linked" ставит neighbors = true, как applyJsonSettings старой страницы (golden-эталон old-board снят после этой мутации); обработка mode === "all" остаётся за Task 9 эталон нельзя править руками, а побочный эффект старой страницы — часть наблюдаемого поведения импорта если нормализация лишняя — убрать две строки в fromJson и перегенерировать эталон
R-14 (design #20) 2026-10-14 В golden-тесте раскладки dateLabels сравниваются только при byDate; эталон не правится и генератор Task 5 не переделывается в сценарии saved-folded (byDate=false) эталон хранит 7 подписей дат от предыдущего сценария: заглушка DOM генератора не очищает полосу дат — артефакт генератора, не поведение страницы при byDate=false приложение, возможно, показывает лишние подписи — это ловит компонентный тест холста (Task 11) и сверка в Task 15