From 14e6e7fcf381586da0c2b1da163db03b2b52bf85 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 15 Oct 2026 00:41:10 +0400 Subject: [PATCH] =?UTF-8?q?docs(openspec):=20web-app-followups=20=E2=80=94?= =?UTF-8?q?=20proposal,=20design,=20=D0=B4=D0=B5=D0=BB=D1=8C=D1=82=D1=8B?= =?UTF-8?q?=20web-app=20=D0=B8=20hub-api=20(#29)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../changes/web-app-followups/.openspec.yaml | 2 + openspec/changes/web-app-followups/design.md | 141 +++++++++++++++ .../changes/web-app-followups/proposal.md | 47 +++++ .../web-app-followups/specs/hub-api/spec.md | 39 +++++ .../web-app-followups/specs/web-app/spec.md | 162 ++++++++++++++++++ 5 files changed, 391 insertions(+) create mode 100644 openspec/changes/web-app-followups/.openspec.yaml create mode 100644 openspec/changes/web-app-followups/design.md create mode 100644 openspec/changes/web-app-followups/proposal.md create mode 100644 openspec/changes/web-app-followups/specs/hub-api/spec.md create mode 100644 openspec/changes/web-app-followups/specs/web-app/spec.md diff --git a/openspec/changes/web-app-followups/.openspec.yaml b/openspec/changes/web-app-followups/.openspec.yaml new file mode 100644 index 0000000..a216797 --- /dev/null +++ b/openspec/changes/web-app-followups/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-15 diff --git a/openspec/changes/web-app-followups/design.md b/openspec/changes/web-app-followups/design.md new file mode 100644 index 0000000..7825481 --- /dev/null +++ b/openspec/changes/web-app-followups/design.md @@ -0,0 +1,141 @@ +## Context + +`main` d38fac8: веб-приложение хаба (`web/`, React 19 + TS + Vite, TanStack Query, React Router) под `/app/`, спека +`openspec/specs/web-app/`, хаб раздаёт сборку (`h_app` в `api.py`). Отложенные находки change `web-app` собраны в +jira-hub#29 (46 пунктов, маркеры сохраняются из PR #27). Устройство, на которое опираются правки: + +- `api/client.ts` — единственный HTTP-клиент: `call()` берёт токен через `getToken()`, 401 → `AuthError`, сеть и 5xx → + `HubDownError`, прочие 4xx → `ApiError {status, error, detail}`. Токен очищается `clearToken("bad")` (из + `useBoardSource.report` и глобального обработчика ошибок `queryClient`), `AuthGate` показывает `LoginScreen`. +- `board/useBoardSource.ts` — источник доски: `source` (hub | file), `event` (одна строка состояния), `active` + (опрашиваемый пакет); подхват незавершённого пакета `viewer` по каждому `board.dataUpdatedAt` через `GET /batches`. + Состояние локально для `BoardPage` и пропадает при уходе со страницы; снимок хаба — в кэше TanStack Query, + раскладка и фильтры — в `localStorage`. +- `GET /batches` отдаёт пакеты без команд (`queue.list_batches`); `GET /batches/{id}` — с `commands[]` (`type`). +- `issues/IssuesPage.tsx` — фильтры в query-строке, варианты — из `filterOptions(board, settings)`. +- `index.html` подключает IBM Plex Sans/Mono с fonts.googleapis.com; `npm audit` — 1 high в `source-map-js@1.2.1` + (транзитивная dev-зависимость), исправление — 1.2.2 (2026-09-30). + +## Goals / Non-Goals + +**Goals:** закрыть jira-hub#29 целиком: каждый пункт — `fixed ` или `rejected <причина>`; поведение доски на тех +же данных совпадает с текущим, кроме пунктов группы A; `make check` и e2e зелёные, e2e устойчив к повторам. + +**Non-Goals:** см. proposal.md. + +## Decisions + +1. **Прокрутка доски (M-3).** При размонтировании `BoardPage` запоминает `window.scrollX/scrollY` (и `scrollLeft` + горизонтальной обёртки холста, если она прокручивается сама) в переменной модуля; при монтировании, после первой + отрисовки холста с данными, восстанавливает. Перезагрузка страницы открывает доску сверху, как сейчас. Состояние + источника не поднимается выше страницы — rejected часть M-3 (решение владельца). +2. **Строка состояния при идущем пакете (T10.3).** `reload()` и `loadFile()` не сбрасывают событие `batch`, пока + `active !== null`; сообщения о файле (`file-error`, `json-applied`) по-прежнему заменяют его (одно событие, M-5 #20), а + следующий ответ опроса возвращает строку пакета. +3. **Ошибка хаба при снимке из файла (T10.4).** Событие `hub-down` получает признак `fromFile`; текст — «хаб не отвечает — + на доске снимок из файла». `hasData` при JSON-снимке без данных хаба — `true` только если в модели есть задачи. +4. **Подхват пакета (T10.6, T10.1).** Кандидат из `GET /batches` (автор `viewer`, `queued`/`running`) проверяется запросом + `GET /batches/{id}`: подхватывается, только если первая команда — `refresh`. Подхват не запускается по обновлению + снимка, которое вызвал сам `finish()` (флаг «снимок после своего пакета»), — без лишнего `GET /batches` (T10.1). +5. **Токен в ошибке (T3.5).** `call()` кладёт в `AuthError` токен, с которым ушёл запрос; `clearToken` вызывается только + если этот токен равен текущему. Запоздавший 401 от прежнего токена игнорируется. +6. **Вход и 4xx (T3.2).** `LoginScreen`: `ApiError` → «хаб отверг запрос: »; `HubDownError` — прежний + текст «хаб не отвечает». +7. **Фильтр вне вариантов (T13.1).** Если значение фильтра из адреса не найдено в `opts[name]`, оно добавляется первым + вариантом с подписью «<значение> (нет на доске)» — как текущее значение «вида работы» в заметке. +8. **Карточка задачи (O-10, O-11).** Ссылки страниц задач — `color: var(--accent)` из темы (как в списке); пустой + `commands` — строка «команд по задаче не было» вместо пустой таблицы. +9. **NUL в пути (T1.1).** `h_app` отвечает 404 `not_found`, если в декодированном пути есть `\x00` или разрешение пути + падает с `ValueError`/`OSError`; остальное поведение `h_app` не меняется. +10. **Шрифты (T2.2).** `@fontsource/ibm-plex-sans` (400, 500, 600) и `@fontsource/ibm-plex-mono` (500), импорт CSS в + `main.tsx`; ссылка на Google Fonts из `index.html` удаляется. Файлы `woff2` попадают в `assets/`, хаб уже отдаёт + `font/woff2`. Версии — точные, `npm install --before=2026-10-01 --save-exact`. +11. **source-map-js (O-1).** `overrides: {"source-map-js": "1.2.2"}` в `package.json`, проверка `npm audit` (0 high) + и сборка; если 1.2.2 не закрывает уязвимость — O-1 rejected с текстом аудита. +12. **Эталоны (T5.1).** В фикстуру `hub-basic` добавляются задачи с длинной темой и узкие/низкие сохранённые раскладки, чтобы + `layout.clamp` давал не только «unset»; эталоны перегенерируются `npm run golden` из `links.html@3931a88`. + Остальные эталоны не меняются. +13. **e2e (T15.4, T15.6).** Порт тестового хаба — одна константа в `web/e2e/hub.ts` и `playwright.config.ts` берёт её же; + `scripts/test-hub.sh` перед стартом проверяет, что порт свободен, и выходит с понятной ошибкой. Готовность — e2e + проходит 10 раз подряд. +14. **Состав плана.** 8 задач: хаб; клиент и вход; источник доски; холст и страница доски; задачи и карточка; модель, + хранилище, эталоны; окружение и e2e; документация. Исполнение — SDD-контроллер (sonnet), задачи с готовым кодом — + haiku, интеграция — sonnet, финальное ревью ветки — opus. + +## Пункты #29 и их судьба + +| Маркер | Что делаем | Задача плана | +|---|---|---| +| T1.1 | 404 на NUL в пути (решение 9) | хаб | +| T1.4 | тест Content-Type для svg/png/ico/woff2/map | хаб | +| T16.1 | убрать дубль `import urllib.*` | хаб | +| T3.2 | 4xx на входе — текст ошибки хаба (решение 6) | клиент и вход | +| T3.3 | содержательный тест 401 и тесты `queryClient`/`queries` | клиент и вход | +| T3.4 | `token.test` проверяет `removeItem` при закрытом хранилище | клиент и вход | +| T3.5 | токен в `AuthError` (решение 5) | клиент и вход | +| T3.6 | интеграционный тест AuthError → LoginScreen | клиент и вход | +| T10.1 | без лишнего `GET /batches` (решение 4) | источник доски | +| T10.3 | строка пакета не стирается (решение 2) | источник доски | +| T10.4 | «на доске снимок из файла» (решение 3) | источник доски | +| T10.5 | тест «поздний ответ» проверяет, что ответ хаба пришёл после файла | источник доски | +| T10.6 | подхват только `refresh` (решение 4) | источник доски | +| M-3 | прокрутка (решение 1); хранение источника — rejected | холст и страница доски | +| T11.1 | стабильные пропсы `Card`, `memo` срабатывает | холст и страница доски | +| T11.2 | отмена `requestAnimationFrame` в `DatesBar` | холст и страница доски | +| T11.3 | тест перетаскивания дорожки | холст и страница доски | +| T11.4 | точные проверки в тестах I-2/I-3 | холст и страница доски | +| T12.1 | `onOpen()` вне updater `setOpen` | холст и страница доски | +| T12.2 | `writeFlag`/`writeHidden` вне updater | холст и страница доски | +| T12.3 | зависимости `useLayoutEffect`, сброс `--head-h` | холст и страница доски | +| T12.4 | MultiSelect: `role="group"` с флажками вместо `listbox` без `option` | холст и страница доски | +| T13.1 | значение фильтра вне вариантов (решение 7) | задачи и карточка | +| O-10 | ссылки цветом темы (решение 8) | задачи и карточка | +| O-11 | «команд по задаче не было» (решение 8) | задачи и карточка | +| T14.2 | ожидания через `waitFor`/`expect.poll` вместо пауз 20 мс | задачи и карточка | +| T14.3 | уникальный id `datalist` (`useId`) | задачи и карточка | +| T2.1 | `canon.ts` — сравнение по кодам символов | модель, хранилище, эталоны | +| T5.1 | фикстуры для `layout.clamp` (решение 12) | модель, хранилище, эталоны | +| T6.3 | прямые тесты `util.ts` | модель, хранилище, эталоны | +| T7.1 | `initialWho` считает `whoItems` один раз | модель, хранилище, эталоны | +| T9.1 | `colWidths()` пропускает только конечные числа | модель, хранилище, эталоны | +| T9.3 | тесты `toggleLane`/`moveLane`/`resetLayout`/`exportFileName`/`mode=all`, закрытое хранилище | модель, хранилище, эталоны | +| T9.4 | порядок импортов `fromJson.ts` | модель, хранилище, эталоны | +| T2.2 | шрифты из `@fontsource` (решение 10) | окружение и e2e | +| O-1 | `overrides` source-map-js (решение 11) | окружение и e2e | +| T4.1 | тест «Задачи и обратно» с обратным переходом | окружение и e2e | +| T4.2 | тест ссылок страницы «не найдено» | окружение и e2e | +| T4.3 | убрать `App.Layout`, тест берёт `Layout` | окружение и e2e | +| T4.4 | один импорт из `@testing-library/react` | окружение и e2e | +| T4.5 | очистка атрибутов в `theme.test` | окружение и e2e | +| T15.1 | высота карточек в geometry сверяется с эталоном, не с формулой | окружение и e2e | +| T15.2 | растяжка через `expect.poll`, без литерала 240 | окружение и e2e | +| T15.4 | порт — одна константа (решение 13) | окружение и e2e | +| T15.6 | проверка свободного порта (решение 13) | окружение и e2e | +| M-5 | rejected: хаб запускается из checkout, wheel не используется | — | + +## Risks / Trade-offs + +- Перегенерация эталонов (T5.1) может показать расхождение порта с `links.html` в `layout.clamp` → это дефект порта, + чинится в коде, эталон не подгоняется. +- Стабилизация пропсов `Card` (T11.1) затрагивает перетаскивание → e2e board/geometry обязательны в задаче. +- Восстановление прокрутки до отрисовки высот даст прыжок → восстанавливать после первой отрисовки с данными. + +## Decisions made without discussion + +Нумерация R-n — заново в пределах этой фичи. + +- **R-1 (решение 1), 15.10.2026.** Прокрутка хранится в переменной модуля, не в `sessionStorage`: переживает переходы + внутри вкладки, но не перезагрузку. Обоснование: владелец согласился на «помнить, пока открыта вкладка» в смысле + переходов; перезагрузка — новая сессия доски. Цена ошибки: если нужна и после F5 — заменить на `sessionStorage`, одна строка. +- **R-2 (решение 4), 15.10.2026.** Тип пакета определяется дополнительным `GET /batches/{id}` по кандидату, а не + изменением `GET /batches` в хабе. Обоснование: API хаба в этом change не меняется (Non-Goals web-app). Цена: один лишний + запрос при найденном кандидате. +- **R-3 (решение 5), 15.10.2026.** Запоздавший 401 со старым токеном молча игнорируется. Обоснование: новый токен уже + проверен `GET /settings`. Цена: если новый токен тоже станет неверным, экран входа появится на следующем запросе. +- **R-4 (решение 7), 15.10.2026.** Значение фильтра вне вариантов подписывается «(нет на доске)». Цена — текст подписи. +- **R-5 (решение 11), 15.10.2026.** O-1 закрывается `overrides`, а не обновлением прямых зависимостей. Обоснование: + уязвимость только в транзитивной dev-зависимости; обновление vite/vitest шире задачи. Цена: `overrides` надо снять, когда + прямые зависимости подтянут 1.2.2 сами. +- **R-6 (решение 12), 15.10.2026.** T5.1 закрывается расширением фикстуры `hub-basic`, а не новой фикстурой. Цена: + перегенерация большого `hub-basic.expected.json`. +- **R-7 (M-5), 15.10.2026.** M-5 rejected — показано владельцу в сводке понимания до утверждения разделов, возражений не было. diff --git a/openspec/changes/web-app-followups/proposal.md b/openspec/changes/web-app-followups/proposal.md new file mode 100644 index 0000000..390a102 --- /dev/null +++ b/openspec/changes/web-app-followups/proposal.md @@ -0,0 +1,47 @@ +## Why + +Change `web-app` (jira-hub#21, PR #27) слит, финальное ревью не нашло Critical и Important. 46 мелких находок +отложены в jira-hub#29: недочёты видимого поведения доски, входа и страниц задач, внешние шрифты, уязвимость в +dev-зависимости, неаккуратный React-код, слабые и недостающие тесты. Решение владельца 15.10.2026 — закрыть #29 +целиком одним change. + +Решения владельца (brainstorming 15.10.2026): +- берутся все 46 пунктов (группы A — поведение, B — окружение, C — качество кода, D — тесты); +- M-3: при возврате с карточки задачи на доску восстанавливается прокрутка; держать в памяти снимок из файла и строку + состояния не нужно — данные идут из хаба, файл — запасной путь (эта часть M-3 — rejected); +- шрифты — те же IBM Plex Sans и Mono, но из сборки, без обращения в интернет; +- M-5 (сборка в wheel) — rejected: хаб запускается из checkout (`uv run --project`), wheel не используется. + +## What Changes + +- **Доска**: восстановление прокрутки при возврате на `/app/board` (M-3); строка «пакет #N выполняется» не стирается + «Перечитать» и загрузкой файла (T10.3); при показанном снимке из файла ошибка хаба — «хаб не отвечает — на доске снимок + из файла» (T10.4); подхватывается только пакет обновления из Jira (T10.6); без лишнего `GET /batches` после + завершения пакета (T10.1). +- **Вход**: ответ 4xx при проверке токена — текст ошибки хаба, а не «хаб не отвечает» (T3.2); 401 на запрос со старым + токеном после входа с новым не выбрасывает на экран входа (T3.5). +- **Задачи**: значение фильтра из адреса, которого нет среди вариантов, показывается в списке (T13.1); ссылки карточки — + цветом темы (O-10); пустой список команд — текстом (O-11). +- **Хаб**: путь `/app/assets/…` с нулевым байтом — 404, а не 500 (T1.1). +- **Сборка**: шрифты из npm-пакетов `@fontsource`, без Google Fonts (T2.2); `source-map-js` поднимается до исправленной + версии через `overrides` (O-1). +- **Код и тесты** без изменения поведения: группы C и D (см. design.md, таблица пунктов). +- Спеки: `web-app` — MODIFIED «Вход по токену», «Доска связей», «Источник данных доски», «Список задач», «Карточка + задачи», «Сборка и разработка»; `hub-api` — MODIFIED «Раздача веб-приложения». + +## Non-Goals + +- Новые страницы, запись в Jira (`viewer-edit`), изменения API хаба. +- Изменения логики доски (модель, выборка, критический путь, проблемы, раскладка): эталоны `links.html@3931a88` + остаются мерилом; меняются только фикстуры эталонов (T5.1), не поведение. +- Хранение состояния доски (снимок файла, событие, опрос) между страницами — rejected часть M-3. +- Включение сборки приложения в wheel (M-5). + +## Impact + +- `web/`: `src/auth`, `src/api`, `src/board` (useBoardSource, BoardPage, BoardCanvas, Card, DatesBar, Dropdown, + MultiSelect, useBoardPrefs, storage, facets), `src/issues`, `src/test`, `e2e/`, `scripts/`, `index.html`, + `package.json`/`package-lock.json` (+ `@fontsource/ibm-plex-sans`, `@fontsource/ibm-plex-mono`, `overrides`). +- `src/jira_hub/api.py` (`h_app`), `tests/test_web_app.py`, `tests/test_api.py`. +- Контракт с агентом, MCP, схема базы и API не меняются. Ключи `localStorage` не меняются. +- Issue: aep/jira-hub#29. diff --git a/openspec/changes/web-app-followups/specs/hub-api/spec.md b/openspec/changes/web-app-followups/specs/hub-api/spec.md new file mode 100644 index 0000000..e6db0ae --- /dev/null +++ b/openspec/changes/web-app-followups/specs/hub-api/spec.md @@ -0,0 +1,39 @@ +## MODIFIED 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` + +#### Scenario: Нулевой байт в пути +- **WHEN** запрошен `GET /app/assets/%00` или `GET /app/assets/a%00.js` +- **THEN** 404 `not_found`, а не 500 diff --git a/openspec/changes/web-app-followups/specs/web-app/spec.md b/openspec/changes/web-app-followups/specs/web-app/spec.md new file mode 100644 index 0000000..9fcfbe2 --- /dev/null +++ b/openspec/changes/web-app-followups/specs/web-app/spec.md @@ -0,0 +1,162 @@ +## MODIFIED Requirements + +### Requirement: Вход по токену +Приложение SHALL работать с API хаба того же хоста под клиентским токеном: хранить его в `localStorage` под ключом +`jira-hub-token` (и в памяти, если хранилище закрыто) и передавать в заголовке `Authorization: Bearer` каждого запроса. +Если токена нет или любой запрос ответил 401, приложение SHALL показывать экран входа вместо страниц: поле токена с +подсказкой `uv run jira-hub tokens`, без панелей страниц и без фоновых запросов, которые могли бы изменить поле ввода. +Введённый токен SHALL проверяться запросом `GET /settings` и сохраняться только при успехе. Действие «Сменить токен» +SHALL очищать токен и показывать экран входа. Ответ 401 SHALL выводить на экран входа, только если запрос ушёл с текущим токеном: запоздавший 401 на +запрос с прежним токеном после входа с новым не учитывается. Если проверка токена получила ответ 4xx, кроме 401, +экран входа SHALL показывать текст ошибки хаба, а не «хаб не отвечает». + +#### Scenario: Первый вход +- **WHEN** приложение открыто впервые, токена нет +- **THEN** показан экран входа; после ввода верного токена открывается запрошенная страница, токен сохранён + +#### Scenario: Устаревший токен +- **WHEN** сохранённый токен не подходит и запрос ответил 401 +- **THEN** показан экран входа с пояснением, что токен не подошёл; панели доски и задач не видны + +#### Scenario: Токен от прежней доски +- **WHEN** в `localStorage` есть `jira-hub-token`, сохранённый страницей `links.html` +- **THEN** приложение входит с ним без запроса токена + +#### Scenario: Запоздавший 401 от прежнего токена +- **WHEN** запрос ушёл с прежним токеном, пользователь вошёл с новым, и после этого запрос ответил 401 +- **THEN** приложение остаётся на странице, новый токен сохранён + +#### Scenario: Хаб отверг проверку токена +- **WHEN** проверка `GET /settings` ответила 400 с `detail` +- **THEN** экран входа показывает текст `detail`, а не «хаб не отвечает» + +### 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. Действия «Сменить токен» в меню +доски нет — оно в верхней панели приложения. Ещё два отличия от `links.html` (R-10, R-18): двойной щелчок по уголку +карточки меряет высоту по содержимому при новой ширине, а не при прежней; указатель при перетаскивании и растяжке +захватывается на нажатом элементе, а не на карточке, поэтому двойной щелчок по уголку сбрасывает размер. Загрузка файла CSV/JSON доступна только после входа, с экрана входа её нет. +При возврате на `/app/board` с другой страницы приложения в той же вкладке доска SHALL восстанавливать прежнюю +прокрутку; после перезагрузки страницы доска открывается сверху. + +#### Scenario: Раскладка переживает переход +- **WHEN** раскладка карточек сохранена страницей `links.html`, открыта `/app/board` с теми же данными +- **THEN** карточки стоят на тех же местах, дорожки в том же порядке и свёртке, фильтры те же + +#### Scenario: Совпадение со старой доской +- **WHEN** на одной фикстуре `GET /board` и одном наборе фильтров построены модель, видимая выборка, критический путь + и проблемы старой страницей и приложением +- **THEN** результаты совпадают + +#### Scenario: Открыть карточку +- **WHEN** на карточке AURORA-1620 нажат значок «Открыть карточку» +- **THEN** открыта `/app/issues/AURORA-1620` внутри приложения + +#### Scenario: Возврат с карточки +- **WHEN** доска прокручена вниз, нажат значок «Открыть карточку», затем выбран раздел «Доска» +- **THEN** доска показана с той же прокруткой + +### 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 +подхватываться при каждой успешной загрузке из хаба; пакеты `viewer` с другими командами не подхватываются. Опрос пакета SHALL прекращаться при ответе 404 и после 5 ошибок +«хаб не отвечает» подряд, с сообщением и снова доступной кнопкой. Шапка доски SHALL показывать +источник данных (данные хаба на время последнего полного прогона; «из файла») и одно текущее событие (загрузка; пакет +выполняется / выполнен / ошибка с текстом; хаб не отвечает — прежние данные, а при снимке из файла — «на доске снимок из файла»; агент офлайн — пакет поставлен; ошибка +разбора файла — «файл не разобран: <причина>»); новое событие заменяет предыдущее целиком. Пока пакет выполняется, +«Перечитать» и загрузка файла SHALL не стирать событие пакета. + +#### Scenario: Поздний ответ хаба не затирает файл +- **WHEN** запрос `GET /board` в пути, и в это время загружен CSV +- **THEN** после ответа хаба на доске по-прежнему данные CSV с пометкой «из файла» + +#### Scenario: Нет второго полного прогона +- **WHEN** нажата «Обновить из Jira» и, пока пакет выполняется, доска перерисована (фильтр, «Перечитать») +- **THEN** кнопка остаётся недоступной, в хабе один пакет, его опрашивает один опрос + +#### Scenario: Пакет пропал +- **WHEN** опрос пакета получил 404 +- **THEN** опрос остановлен, показано сообщение, кнопка «Обновить из Jira» снова доступна + +#### Scenario: Пакет после «Перечитать» +- **WHEN** пакет `viewer` поставлен с другой вкладки и не завершён, нажато «Перечитать» +- **THEN** доска показывает «пакет #N выполняется» и по завершении перечитывает данные + +#### Scenario: «Перечитать» во время пакета +- **WHEN** пакет `viewer` выполняется и нажато «Перечитать» +- **THEN** шапка по-прежнему показывает «пакет #N выполняется» + +#### Scenario: Хаб недоступен при снимке из файла +- **WHEN** на доске снимок из CSV, нажато «Перечитать», хаб не ответил +- **THEN** шапка показывает «хаб не отвечает — на доске снимок из файла» + +#### Scenario: Чужой пакет не подхватывается +- **WHEN** незавершённый пакет автора `viewer` состоит из команд `ensure_fields` +- **THEN** доска его не опрашивает, кнопка «Обновить из Jira» доступна + +### Requirement: Список задач +Страница `/app/issues` SHALL показывать задачи зеркала из `GET /issues` таблицей: ключ (ссылка на карточку), тема, +тип, статус Jira, исполнитель, спринт, срок (`planned_end`, иначе `duedate`), эпик (имя эпика, иначе ключ), вид работы, +статус по факту, признак «сосед». Фильтры SHALL соответствовать параметрам `GET /issues` (спринт, исполнитель, вид +работы, статус по факту, статус Jira, метка, эпик, только соседи, поиск по ключу или теме), храниться в query-строке +адреса и применяться сервером; варианты выпадающих списков SHALL браться из снимка `GET /board`; значение фильтра из адреса, которого нет среди +вариантов, SHALL показываться в списке с пометкой «(нет на доске)». Задачи SHALL +загружаться порциями по 50 строк; «Показать ещё» SHALL быть доступна, пока последняя порция полная. + +#### Scenario: Фильтр в адресе +- **WHEN** выбран спринт «AWPCA_Спринт 2» и исполнитель +- **THEN** адрес содержит `sprint=` и `assignee=`, таблица — результат `GET /issues` с этими параметрами; открытие этого + адреса в новой вкладке показывает ту же выборку + +#### Scenario: Конец списка +- **WHEN** вторая порция вернула 17 задач +- **THEN** в таблице 67 задач, «Показать ещё» не показана + +#### Scenario: Фильтр по закрытому спринту +- **WHEN** открыт адрес `/app/issues?sprint=AWPCA_Спринт 0`, а в снимке доски такого спринта нет +- **THEN** выпадающий список «Спринт» показывает «AWPCA_Спринт 0 (нет на доске)» выбранным, таблица — по этому фильтру + +### 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` + +#### Scenario: Задача без команд +- **WHEN** по AURORA-1620 не было команд +- **THEN** в разделе «Последние команды» — текст «команд по задаче не было», таблицы нет + +### Requirement: Сборка и разработка +Приложение SHALL быть npm-проектом в `web/` (React, TypeScript, Vite) с точными версиями зависимостей и +`package-lock.json`. `make web` SHALL устанавливать зависимости (`npm ci`) и собирать приложение в +`src/jira_hub/web/app/`; каталог сборки не хранится в git. Шрифты и прочие ресурсы +страницы SHALL входить в сборку: приложение не обращается к внешним адресам. `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` не показывает файлов сборки + +#### Scenario: Без интернета +- **WHEN** приложение открыто на машине без доступа в интернет +- **THEN** страница не запрашивает ресурсов вне хаба, текст набран IBM Plex Sans