docs(openspec): change web-app — веб-приложение хаба на React (#21)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-14 19:18:05 +04:00
parent 3931a88ce8
commit 69a21d63b6
5 changed files with 470 additions and 0 deletions

View File

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-14

View File

@ -0,0 +1,186 @@
## 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` позже |

View File

@ -0,0 +1,53 @@
## Why
Доска связей (`src/jira_hub/web/links.html`, change `viewer-live`) работает на живых данных хаба, но это один HTML-файл
с ~900 строками императивного JS: каждая новая возможность (запись из доски — `viewer-edit`, другие экраны моста)
ложится на него всё тяжелее, а гонки загрузки и опроса (jira-hub#20, M-1…M-6) вытекают из его устройства. Решение
владельца 14.10.2026: завернуть хаб в нормальное веб-приложение на React, где доска — одна из страниц.
Решения владельца (brainstorming 14.10.2026):
- React + TypeScript + Vite; статику отдаёт сам хаб; открывают пока только на Mac mini; вход — клиентский токен
в `localStorage`, как на доске;
- доска переписывается на React (вариант A): логика — чистые TS-модули с golden-тестами против старой страницы,
отрисовка — React-компоненты с SVG; поведение доски не расширяется, кроме действия «Открыть карточку»;
- в первой версии, кроме доски, — страница «Задачи»: список зеркала с фильтрами, карточка задачи, правка служебных заметок;
- приложение — под префиксом `/app/`; `/` и `/links` перенаправляют на доску; сборка не хранится в git, выкатка —
`git pull`, `make web`, перезапуск;
- словари видов работ и типов связей — из `GET /settings`, не дублируются в коде.
## What Changes
- **Новый проект `web/`** (npm): приложение React + TS + Vite, сборка в `src/jira_hub/web/app/` (в `.gitignore`).
- Оболочка: экран входа по клиентскому токену, навигация «Доска» / «Задачи», «Сменить токен».
- Страница «Доска» (`/app/board`) — перенос `links.html` один в один; закрывает M-1…M-6 jira-hub#20 устройством
загрузки (явный источник данных, один опрос пакета, остановка опроса на 404 и после 5 ошибок подряд, одна строка
состояния, экран входа без панели доски); действие «Открыть карточку» у карточки доски.
- Страница «Задачи» (`/app/issues`, `/app/issues/{key}`) — список `GET /issues` с фильтрами в адресной строке и
порциями по 50, карточка `GET /issues/{key}` (поля, описание, связи, последние команды), правка заметок
`PUT /issues/{key}/note`.
- **jira-hub**: раздача приложения — `GET /app/assets/*` (файлы сборки), прочие `GET /app…` → `index.html`, 503 с
подсказкой при отсутствии сборки, `GET /` и `GET /links` → 302 на `/app/board`; `links.html` удаляется; тест
`mirror.board(own=False)` (M-7 #20).
- `Makefile`: `make web` (сборка), `make check` (pytest + lint + тесты фронта).
- Спеки: новая `web-app`; `hub-api` — MODIFIED «Процесс и доступ», ADDED «Раздача веб-приложения», REMOVED «Страница
доски связей».
## Non-goals
- Запись в Jira из интерфейса (спринт, срок, связи, исполнитель) — change `viewer-edit`.
- Страницы очереди, агента и синхронизаций, настроек — позже.
- Изменения API хаба для приложения (новые поля, общее количество в `GET /issues`, адрес Jira в настройках).
- Изменения логики доски (граф, раскладка, критический путь, проблемы, таблица, тема), кроме «Открыть карточку».
- Доступ со других машин сети, вход без токена, хранение раскладки в базе хаба.
- Разбор разметки Jira в описании задачи.
## Impact
- `jira-hub`: новый каталог `web/` (npm-проект), `api.py` (маршруты `/app…`, редирект `/`/`/links`, удаление `PAGE`),
`src/jira_hub/web/links.html` удаляется, `.gitignore`, `Makefile`, тесты API и `mirror`, README, `ARCHITECTURE.md`,
`CLAUDE.md`.
- Новые зависимости — только для разработки и сборки фронта (Node 24 уже есть на Mac mini); у Python-пакета
зависимостей не прибавляется.
- Контракт с агентом и MCP не меняется; схема базы не меняется.
- Ключи `localStorage` (`jira-hub-token`, `graph-<имя>…`) сохраняются: вход и раскладки переживают переход.
- Issue: aep/jira-hub#21; закрывает jira-hub#20.

View File

@ -0,0 +1,66 @@
## MODIFIED Requirements
### Requirement: Процесс и доступ
`jira-hub serve` SHALL запускать один процесс: HTTP на `0.0.0.0:<port>` (по умолчанию 8790), база и настройки в
`~/.local/share/jira-hub/` (переопределяется `JIRA_HUB_HOME`). Все маршруты API SHALL требовать
`Authorization: Bearer <token>`: агентский токен открывает только `/agent/*`, клиентский — всё остальное. Исключение —
раздача веб-приложения (`GET /app`, `GET /app/…`) и перенаправления `GET /`, `GET /links`: это статические файлы без
данных, они отдаются без токена. Ответы API — JSON, ошибки — `{error, detail}` с кодами 400 (неверное тело или
`Content-Length`), 401, 404, 409, 413 (тело больше предела), 422, 500 (внутренняя ошибка); реле общей папки
дополнительно отвечает 502. Методы, кроме GET, POST и PUT, отвечают 501 стандартной страницей `http.server`, не JSON.
#### Scenario: Чужой токен
- **WHEN** агентским токеном вызван `GET /issues`
- **THEN** 401
#### Scenario: Неподдерживаемый метод
- **WHEN** `DELETE /issues/AURORA-1`
- **THEN** 501, тело не JSON; база не меняется
#### Scenario: Страница без токена
- **WHEN** `GET /app/board` без заголовка `Authorization`, сборка приложения есть
- **THEN** 200, `Content-Type: text/html; charset=utf-8`, тело — `index.html` сборки; данных зеркала в нём нет
## ADDED Requirements
### Requirement: Раздача веб-приложения
Хаб SHALL отдавать собранное веб-приложение (спека `web-app`) из каталога пакета `src/jira_hub/web/app/`, читая файлы
с диска на каждый запрос:
- `GET /app/assets/<путь>` — файл из `assets/` сборки; путь, который после разрешения выходит за `assets/` или не
существует, — 404; `Content-Type` по расширению (`.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`, прочее — `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` сборки нет — на любой `GET /app…` 503 `text/html; charset=utf-8` с текстом, что приложение не
собрано и нужно выполнить `make web` в каталоге jira-hub;
- `GET /` и `GET /links` — 302 с `Location: /app/board`.
Сборка приложения не хранится в git; её создаёт `make web`.
#### Scenario: Файл сборки
- **WHEN** в сборке есть `assets/index-abc123.js`, запрошен `GET /app/assets/index-abc123.js`
- **THEN** 200, `Content-Type: text/javascript; charset=utf-8`, `Cache-Control` с `immutable`, тело — содержимое файла
#### Scenario: Путь приложения
- **WHEN** запрошен `GET /app/issues/AURORA-1620`
- **THEN** 200, тело — `index.html` сборки, `Cache-Control: no-cache`
#### Scenario: Выход за каталог сборки
- **WHEN** запрошен `GET /app/assets/../../api.py` (в том числе в виде `%2e%2e`)
- **THEN** 404, содержимое файлов вне `assets/` не отдаётся
#### Scenario: Приложение не собрано
- **WHEN** каталога сборки нет, запрошен `GET /app/board`
- **THEN** 503, тело — HTML с подсказкой `make web`
#### Scenario: Старый адрес доски
- **WHEN** запрошен `GET /links` или `GET /`
- **THEN** 302, `Location: /app/board`
## REMOVED Requirements
### Requirement: Страница доски связей
**Reason**: страница `links.html` заменена страницей «Доска» веб-приложения (спека `web-app`, требование «Доска связей»);
поведение перенесено без изменений, кроме закрытых недочётов jira-hub#20 и действия «Открыть карточку».
**Migration**: `GET /` и `GET /links` перенаправляют на `/app/board`; токен (`jira-hub-token`) и раскладки
(`graph-<имя>…`) в `localStorage` читаются приложением под теми же ключами.

View File

@ -0,0 +1,163 @@
## ADDED Requirements
### Requirement: Вход по токену
Приложение SHALL работать с API хаба того же хоста под клиентским токеном: хранить его в `localStorage` под ключом
`jira-hub-token` (и в памяти, если хранилище закрыто) и передавать в заголовке `Authorization: Bearer` каждого запроса.
Если токена нет или любой запрос ответил 401, приложение SHALL показывать экран входа вместо страниц: поле токена с
подсказкой `uv run jira-hub tokens`, без панелей страниц и без фоновых запросов, которые могли бы изменить поле ввода.
Введённый токен SHALL проверяться запросом `GET /settings` и сохраняться только при успехе. Действие «Сменить токен»
SHALL очищать токен и показывать экран входа.
#### Scenario: Первый вход
- **WHEN** приложение открыто впервые, токена нет
- **THEN** показан экран входа; после ввода верного токена открывается запрошенная страница, токен сохранён
#### Scenario: Устаревший токен
- **WHEN** сохранённый токен не подходит и запрос ответил 401
- **THEN** показан экран входа с пояснением, что токен не подошёл; панели доски и задач не видны
#### Scenario: Токен от прежней доски
- **WHEN** в `localStorage` есть `jira-hub-token`, сохранённый страницей `links.html`
- **THEN** приложение входит с ним без запроса токена
### Requirement: Оболочка и адреса
Приложение SHALL открываться под префиксом `/app/` и иметь страницы `/app/board` (доска связей), `/app/issues` (список
задач) и `/app/issues/{key}` (карточка задачи); `/app` и `/app/` SHALL вести на `/app/board`, неизвестный путь —
на страницу «не найдено» со ссылками на разделы. Верхняя панель SHALL содержать переходы «Доска», «Задачи» и действие
«Сменить токен». Неожиданная ошибка отрисовки страницы SHALL показываться сообщением с кнопкой «Перезагрузить», не
пустым экраном.
#### Scenario: Переход между разделами
- **WHEN** на доске выбран раздел «Задачи»
- **THEN** адрес — `/app/issues`, показан список задач; «назад» возвращает на доску
### Requirement: Ошибки хаба
Приложение SHALL различать ошибки запросов к хабу: 401 — экран входа; хаб не отвечает (сеть, 5xx) — сообщение
«хаб не отвечает» с возможностью повторить, ранее загруженные данные остаются на экране; прочие 4xx — текст `detail`
ответа рядом с действием, которое его вызвало.
#### Scenario: Хаб остановлен
- **WHEN** доска показана, хаб остановлен, нажато «Перечитать»
- **THEN** на доске прежние данные и сообщение «хаб не отвечает — показаны прежние данные»
### Requirement: Справочники из настроек хаба
Приложение SHALL брать перевод меток видов работ (`kind_labels`) и типов связей Jira (`link_types`) из `GET /settings`
и не содержать собственных копий этих словарей. Если нужного словаря в настройках нет, страница SHALL сообщать, какого
словаря не хватает, вместо пустой доски или списка без подписей.
#### Scenario: Новый тип связи в настройках
- **WHEN** в `link_types` настроек добавлен тип и в зеркале есть связь этого типа
- **THEN** после перезагрузки страницы доска показывает связь этого типа без изменения кода приложения
### Requirement: Доска связей
Страница `/app/board` SHALL воспроизводить поведение доски связей хаба (`links.html` ревизии 3931a88) без изменений,
кроме перечисленных в этом требовании и в требовании «Источник данных доски»: построение модели из `GET /board`, CSV
фильтра Jira и JSON доски (`arm-ka-links/1`); дорожки по эпикам со свёрткой и перестановкой, служебные дорожки внизу;
карточки с перетаскиванием, растяжкой, размером по содержимому и привязкой к сетке; стрелки связей с выравниванием;
фильтры по спринтам и исполнителям; легенда со скрытием видов работ и типов связей; «+ соседи по связям», «⇄ передачи»,
«скрыть без связей», «⚑ критический путь», «▥ по датам» с колонками дат и их растяжкой; ящик «Проблемы»; таблица
связей; экспорт и импорт JSON; тема; «Сбросить раскладку». Раскладка, дорожки, фильтры и тема SHALL храниться в
`localStorage` под теми же ключами `graph-<имя>…`, что у `links.html`, с той же миграцией старых ключей.
Результат построения модели, видимой выборки, критического пути и проблем SHALL совпадать с результатом `links.html`
на одних и тех же данных и фильтрах. У карточки задачи (не у заглушки) SHALL быть значок «Открыть карточку»,
ведущий на `/app/issues/{key}`; ключ карточки по-прежнему открывает задачу в Jira. Действия «Сменить токен» в меню
доски нет — оно в верхней панели приложения. Загрузка файла CSV/JSON доступна только после входа, с экрана входа её нет.
#### Scenario: Раскладка переживает переход
- **WHEN** раскладка карточек сохранена страницей `links.html`, открыта `/app/board` с теми же данными
- **THEN** карточки стоят на тех же местах, дорожки в том же порядке и свёртке, фильтры те же
#### Scenario: Совпадение со старой доской
- **WHEN** на одной фикстуре `GET /board` и одном наборе фильтров построены модель, видимая выборка, критический путь
и проблемы старой страницей и приложением
- **THEN** результаты совпадают
#### Scenario: Открыть карточку
- **WHEN** на карточке AURORA-1620 нажат значок «Открыть карточку»
- **THEN** открыта `/app/issues/AURORA-1620` внутри приложения
### Requirement: Источник данных доски
Доска SHALL хранить явный источник данных — хаб или файл. Пока показан снимок из файла (CSV или JSON-снимок), ответы
хаба SHALL его не заменять; «Перечитать» SHALL возвращать источник «хаб» и загружать `GET /board` заново без обращения к
Jira. Импорт JSON в режиме хаба SHALL применять раскладку, дорожки и фильтры и перечитывать хаб. «Обновить из Jira»
SHALL проверять агента (`GET /agent`; офлайн — предупреждение, пакет всё равно ставится), ставить
`POST /refresh {full: true, author: "viewer"}`, опрашивать `GET /batches/{id}` каждые 3 с одним опросом до
терминального статуса пакета и затем перечитывать `GET /board`. Кнопка «Обновить из Jira» SHALL быть недоступна,
пока запрос `/refresh` в пути или пакет выполняется. Незавершённый пакет `refresh` с автором `viewer` SHALL
подхватываться при каждой успешной загрузке из хаба. Опрос пакета SHALL прекращаться при ответе 404 и после 5 ошибок
«хаб не отвечает» подряд, с сообщением и снова доступной кнопкой. Строка состояния доски SHALL показывать одно
текущее состояние (загрузка; данные хаба на время последнего полного прогона; из файла; пакет выполняется / выполнен /
ошибка с текстом; хаб не отвечает — прежние данные; агент офлайн — пакет поставлен), а ошибка разбора файла — отдельным
текстом «файл не разобран: <причина>».
#### Scenario: Поздний ответ хаба не затирает файл
- **WHEN** запрос `GET /board` в пути, и в это время загружен CSV
- **THEN** после ответа хаба на доске по-прежнему данные CSV с пометкой «из файла»
#### Scenario: Нет второго полного прогона
- **WHEN** нажата «Обновить из Jira» и, пока пакет выполняется, доска перерисована (фильтр, «Перечитать»)
- **THEN** кнопка остаётся недоступной, в хабе один пакет, его опрашивает один опрос
#### Scenario: Пакет пропал
- **WHEN** опрос пакета получил 404
- **THEN** опрос остановлен, показано сообщение, кнопка «Обновить из Jira» снова доступна
#### Scenario: Пакет после «Перечитать»
- **WHEN** пакет `viewer` поставлен с другой вкладки и не завершён, нажато «Перечитать»
- **THEN** доска показывает «пакет #N выполняется» и по завершении перечитывает данные
### Requirement: Список задач
Страница `/app/issues` SHALL показывать задачи зеркала из `GET /issues` таблицей: ключ (ссылка на карточку), тема,
тип, статус Jira, исполнитель, спринт, срок (`planned_end`, иначе `duedate`), эпик (имя эпика, иначе ключ), вид работы,
статус по факту, признак «сосед». Фильтры SHALL соответствовать параметрам `GET /issues` (спринт, исполнитель, вид
работы, статус по факту, статус Jira, метка, эпик, только соседи, поиск по ключу или теме), храниться в query-строке
адреса и применяться сервером; варианты выпадающих списков SHALL браться из снимка `GET /board`. Задачи SHALL
загружаться порциями по 50 строк; «Показать ещё» SHALL быть доступна, пока последняя порция полная.
#### Scenario: Фильтр в адресе
- **WHEN** выбран спринт «AWPCA_Спринт 2» и исполнитель
- **THEN** адрес содержит `sprint=` и `assignee=`, таблица — результат `GET /issues` с этими параметрами; открытие этого
адреса в новой вкладке показывает ту же выборку
#### Scenario: Конец списка
- **WHEN** вторая порция вернула 17 задач
- **THEN** в таблице 67 задач, «Показать ещё» не показана
### Requirement: Карточка задачи
Страница `/app/issues/{key}` SHALL показывать задачу из `GET /issues/{key}`: поля зеркала, ссылку на задачу в Jira,
описание как текст с сохранёнными переносами строк (без разбора разметки Jira), исходящие и входящие связи с подписью
типа (логический тип из `link_types`, иначе имя типа Jira) и ссылками на карточки связанных задач, 10 последних команд
по задаче (id, пакет, тип, статус, время завершения, ошибка). Если задачи нет в зеркале (404), страница SHALL сообщать
об этом со ссылкой на список.
#### Scenario: Связанная задача
- **WHEN** у AURORA-1620 исходящая связь `ASUPR_Блокер_Начала` на AURORA-1621
- **THEN** в карточке «блокирует начало → AURORA-1621», ключ ведёт на `/app/issues/AURORA-1621`
### Requirement: Служебные заметки
Карточка задачи SHALL давать править служебные заметки: «вид работы» — выбор из значений `kind_labels` настроек
(и текущего значения, если его там нет, и пустого), «статус по факту» — текст с подсказками из ключей `status_labels`,
«сделано», «осталось», «заметка владельца» — многострочный текст. Сохранение SHALL отправлять
`PUT /issues/{key}/note` только с изменёнными полями и `updated_by: "web"`; очищенное поле отправляется как `null`.
После успеха SHALL показываться сохранённая заметка с временем и автором; при 422 — текст ошибки у формы без потери
введённого. Уход со страницы с несохранёнными правками SHALL требовать подтверждения. Заметки пишутся только в хаб,
в Jira приложение ничего не отправляет.
#### Scenario: Только изменённые поля
- **WHEN** в заметке изменено только «осталось» и нажато «Сохранить»
- **THEN** тело `PUT /issues/{key}/note` — `{rest_text: "…", updated_by: "web"}`
#### Scenario: Уход с несохранёнными правками
- **WHEN** в форме заметки есть несохранённая правка и выбран раздел «Доска»
- **THEN** приложение спрашивает подтверждение; при отказе остаётся в карточке с введённым текстом
### Requirement: Сборка и разработка
Приложение SHALL быть npm-проектом в `web/` (React, TypeScript, Vite) с точными версиями зависимостей и
`package-lock.json`. `make web` SHALL устанавливать зависимости (`npm ci`) и собирать приложение в
`src/jira_hub/web/app/`; каталог сборки не хранится в git. `make check` SHALL запускать `uv run pytest`, проверку типов
и линтер фронта и тесты фронта; зелёным считается только весь набор. `npm run dev` SHALL поднимать сервер разработки с
прокси маршрутов API на хаб из `JIRA_HUB_URL` (по умолчанию тестовый `http://127.0.0.1:8791`).
#### Scenario: Сборка с нуля
- **WHEN** в чистом checkout выполнен `make web`
- **THEN** в `src/jira_hub/web/app/` есть `index.html` и `assets/`; `git status` не показывает файлов сборки