Веб-приложение хаба на React: доска связей и задачи (#21) #27

Merged
aep merged 31 commits from feat/21-web-app into main 2026-10-14 23:19:56 +03:00
133 changed files with 23164 additions and 1275 deletions

4
.gitignore vendored
View File

@ -12,3 +12,7 @@ __pycache__/
*.sqlite*
.pytest_cache/
.wt/
src/jira_hub/web/app/
web/test-results/
web/playwright-report/
web/.e2e-hub/

View File

@ -13,7 +13,7 @@
| `commands` | контракт типов команд, валидация, временные ключи, оценка, подтверждение по состоянию | `mirror` |
| `queue` | пакеты, выдача (`claim`), отчёты, отмена, повтор, автообновление | `commands`, `mirror`, `settings`, `db` |
| `backup` | копии файла базы, ротация | `paths` |
| `api` | HTTP-сервер, маршруты, авторизация по токенам, фоновый `tick`; страница доски GET /, /links (файл web/links.html, без токена) и снимок GET /board | `queue`, `mirror`, `settings`, `backup`, `relay`, `release` |
| `api` | HTTP-сервер, маршруты, авторизация по токенам, фоновый `tick`; раздача веб-приложения `/app…` (каталог `web/app` пакета, без токена) и перенаправление `/`, `/links`; снимок `GET /board` | `queue`, `mirror`, `settings`, `backup`, `relay`, `release` |
| `relay` | реле общей папки (настройка `relay_dir`): поток в `serve`, файлы `req/` → свой HTTP (адрес `serve`, для `0.0.0.0` — `127.0.0.1`) → `resp/`; агент с `hub_url = folder:<путь>` ходит через него; HTTP-обработчики о нём не знают | `settings` |
| `release` | опубликованная версия агента: `release/agent.py` + `manifest.json` в каталоге данных, `publish` (только вверх), `current`, `load` (с проверкой sha256), подсказка `hint` для `agent_status` | `paths` |
| `client` | HTTP-клиент хаба | `db`, `paths`, `settings` (только для `from_home`) |
@ -21,10 +21,12 @@
| `importer` | разовый импорт CSV-пакета в заметки, сверка с зеркалом | `mirror`, `settings` |
| `cli` | `init`, `serve`, `import`, `mcp`, `tokens`, `agent publish` | `api`, `mcp_server`, `importer`, `release`, `settings`, `db`, `paths` |
`src/jira_hub/web/links.html` — страница доски связей: один HTML без зависимостей, хаб отдаёт его как есть. Данные
страница берёт из `GET /board` с клиентским токеном и переводит в модель доски сама (вид работы по меткам, имена
эпиков, логические типы связей); `mirror.board` отдаёт зеркало без преобразований — тот же формат будет читать
веб-приложение.
`web/` — веб-приложение хаба (React + TypeScript + Vite): вход по клиентскому токену, страницы «Доска» (`/app/board`)
и «Задачи» (`/app/issues`, `/app/issues/{key}`). Собирается `make web` в `src/jira_hub/web/app/`; `api` отдаёт сборку
без токена (`/app/assets/*` — файлы, прочие `/app…` — `index.html`, 503 без сборки), `/` и `/links` перенаправляет на
`/app/board`. Данные — только через HTTP API с клиентским токеном; `mirror.board` отдаёт зеркало без преобразований,
перевод в модель доски — в `web/src/board/model`. Логика доски — чистые модули `web/src/board/{model,analysis,layout}`
и `storage.ts` (ключи `localStorage` прежней страницы), отрисовка — `web/src/board/components`.
Цепочки: `api → queue → commands/mirror/settings → db`; `db → backup → paths`, `db → paths`; `api → relay → settings`; `mcp_server → client → HTTP API`; `importer → mirror/settings`.
@ -41,8 +43,9 @@
- `queue` не создаёт команд сам: повтор после failed — новый пакет. Выданная без отчёта команда довыдаётся при следующем `claim` (до 3 выдач, затем failed).
- `mcp_server` не импортирует `queue`/`mirror`/`db` напрямую и ходит только через `client`.
- `commands`, `mirror`, `queue` не знают про HTTP; HTTP живёт только в `api`, `client` и `relay` (реле — клиент своего же HTTP).
- Веб-приложение (`web/`) ходит в хаб только через HTTP API (`web/src/api/client.ts`); словари `kind_labels`, `link_types`, `status_labels` берёт из `GET /settings` и своих копий не держит.
## Открытые вопросы
- Просмотрщик на живых данных и правки из UI — отдельные change'и (`viewer-live`, `viewer-edit`).
- Правки из UI в Jira (спринт, срок, связи, исполнитель) — change `viewer-edit`; страницы очереди, агента и настроек — позже.
- Автоподстройка темпа по фактам 429 не реализована: хаб только показывает их.
- Незакрытые SQLite-соединения и сокеты в ряде тестов (ResourceWarning при `-W error::ResourceWarning`).

View File

@ -1,9 +1,10 @@
# jira-hub
## Что это
Хаб моста Jira для АРМ КА: единственный источник истины по задачам проекта AURORA (метка AWPCA) на Mac mini (`192.168.0.106`). Хранит зеркало задач, служебные заметки и очередь команд в SQLite, отдаёт HTTP API в локальной сети (агенту на виртуалке `jira-agent` и интерфейсам) и MCP-сервер для Claude. Главный сценарий: из чата поставить пакет команд, агент на виртуалке выполняет их в Jira по правилу «проверь, потом сделай» и возвращает состояние задач в зеркало. Спеки: `openspec/specs/`; текущего change нет (последний — `openspec/changes/archive/2026-10-14-agent-self-update/`).
Хаб моста Jira для АРМ КА: единственный источник истины по задачам проекта AURORA (метка AWPCA) на Mac mini (`192.168.0.106`). Хранит зеркало задач, служебные заметки и очередь команд в SQLite, отдаёт HTTP API в локальной сети (агенту на виртуалке `jira-agent` и интерфейсам) и MCP-сервер для Claude. Главный сценарий: из чата поставить пакет команд, агент на виртуалке выполняет их в Jira по правилу «проверь, потом сделай» и возвращает состояние задач в зеркало. Спеки: `openspec/specs/`; текущий change — `openspec/changes/web-app/`.
## Стек и ограничения
- Веб-приложение — `web/` (React 19 + TypeScript + Vite, TanStack Query, Vitest, Playwright; Node 24, npm); сборка в `src/jira_hub/web/app/` не в git.
- Python 3.13, uv, hatchling, pytest; HTTP на stdlib (`http.server`), БД — SQLite (stdlib), MCP — пакет `mcp` (закреплён `<2`, рулинг R-10).
- Хаб не знает пароль Jira и не ходит в Jira: учётные данные и сеть до Jira только у агента.
- Зеркало задач пишет только `mirror.apply_state` по загрузке состояния от агента (`POST /agent/state`); правки идут командами через очередь.
@ -22,13 +23,15 @@
- `uv sync` — установка окружения.
- `uv run jira-hub init --config PATH [--force]` — настройки проекта из `config.example.json`, создание токенов.
- `uv run jira-hub serve [--host] [--port]` — локальный запуск хаба.
- Доска связей — `http://127.0.0.1:8790/` (страница `src/jira_hub/web/links.html`, вход по клиентскому токену).
- Веб-приложение — `http://127.0.0.1:8790/app/` (доска — `/app/board`, задачи — `/app/issues`; вход по клиентскому токену); `/` и `/links` перенаправляют на доску.
- `make web` — сборка приложения (после `git pull` на Mac mini, до перезапуска хаба); `make check` — pytest + lint и тесты фронта; `cd web && npm run dev` — разработка с прокси на тестовый хаб `bash web/scripts/test-hub.sh` (:8791); `cd web && npm run e2e` — Playwright; `cd web && npm run golden` — перегенерировать эталоны доски из `links.html@3931a88` (только при смене фикстур).
- `uv run jira-hub import FOLDER` — импорт CSV-пакета `admin/jira-sync`; `uv run jira-hub mcp` — MCP по stdio; `uv run jira-hub tokens` — показать токены.
- `uv run jira-hub agent publish PATH` — опубликовать версию агента для самообновления (только вверх).
## Тесты
- Golden-тесты: `tests/fixtures/<name>.*` + эталон `<name>.expected.*`. Внешние сервисы в тестах мокаются.
- SQLite в памяти или во временном `JIRA_HUB_HOME` (`tmp_path`); API поднимается на порту 0 в отдельном потоке. Jira и агент в тестах не участвуют.
- Фронт: golden-эталоны доски — `web/src/board/__fixtures__/*.expected.json` (их пишет `web/scripts/golden-from-links.ts` из старой страницы); тесты рядом с кодом `*.test.ts(x)`; e2e — `web/e2e/` на тестовом хабе.
## Первая подсистема
Очередь команд (`queue.py`, Task 5): пакеты, порядок `(batch.created_at, seq)`, одна команда в полёте, временные ключи `new:<n>`, подтверждение по состоянию зеркала. Остальное (API, MCP, импорт) надстраивается над ней.

10
Makefile Normal file
View File

@ -0,0 +1,10 @@
.PHONY: web check
# сборка веб-приложения в src/jira_hub/web/app/ (каталог не в git; хаб читает его на каждый запрос)
web:
cd web && npm ci && npm run build
# весь набор проверок: хаб и фронт; зелёным считается только всё вместе
check:
uv run pytest -q
cd web && npm run lint && npm test

View File

@ -114,7 +114,7 @@ uv run jira-hub agent publish ../jira-agent/src/agent.py
## HTTP API
Все маршруты, кроме страницы доски (`GET /`, `GET /links`), требуют `Authorization: Bearer <токен>`; ответы JSON,
Все маршруты, кроме веб-приложения (`GET /app…`) и перенаправления старых адресов (`GET /`, `GET /links`), требуют `Authorization: Bearer <токен>`; ответы JSON,
ошибки `{error, detail}` (400/401/404/409/422).
| Маршрут | Токен | Назначение |
@ -129,7 +129,7 @@ uv run jira-hub agent publish ../jira-agent/src/agent.py
| `GET /issues` | клиент | фильтры `sprint`, `assignee`, `work_kind`, `fact_status`, `status`, `label`, `epic`, `neighbor`, `q`, `limit`, `offset` |
| `GET /issues/{key}` | клиент | задача целиком |
| `GET /board` | клиент | снимок для доски: все задачи с `fact_status`/`work_kind`, все связи, `last_sync` — последний успешный полный прогон |
| `GET /`, `GET /links` | — | страница доски связей (HTML без данных) |
| `GET /`, `GET /links` | — | перенаправление на `/app/board` |
| `PUT /issues/{key}/note` | клиент | заметки (частично) |
| `POST /batches`, `GET /batches`, `GET /batches/{id}` | клиент | пакеты команд |
| `POST /batches/{id}/cancel` | клиент | отмена пакета |
@ -143,18 +143,22 @@ TOKEN=$(uv run jira-hub tokens | sed -n 's/^клиентский токен: //p
curl -s -H "Authorization: Bearer $TOKEN" "http://127.0.0.1:8790/issues?sprint=Спринт%202&limit=20"
```
## Доска связей
## Веб-приложение
`http://127.0.0.1:8790/` — доска «Спринт 2 линк» на данных зеркала: дорожки по эпикам, стрелки блокировок, режим
«по датам», критический путь, проблемы. При первом входе спрашивает клиентский токен (`uv run jira-hub tokens`) и
хранит его в браузере; `localhost` и `127.0.0.1` — разные сайты для браузера, токен и раскладка у каждого свои.
Адреса: `http://127.0.0.1:8790/app/` — доска `/app/board`, задачи `/app/issues`, карточка `/app/issues/{key}`; `/` и
`/links` перенаправляют на доску. Вход по клиентскому токену (`uv run jira-hub tokens`), токен хранится в браузере;
`localhost` и `127.0.0.1` — разные сайты для браузера, токен и раскладка у каждого свои. Выкатка на Mac mini:
- «Перечитать» — снимок зеркала заново, без Jira; «Обновить из Jira» — пакет `refresh` по базовой JQL (автор
`viewer`), страница ждёт его и перечитывает доску; в шапке — время последнего полного обновления.
- Раскладка, дорожки и фильтры хранятся в браузере. Перенос из старой страницы: там «⋯ → Экспорт JSON», здесь «файл…»
→ этот JSON: применяются раскладка и фильтры, задачи остаются из хаба.
- «файл…» с CSV из фильтра Jira — снимок файла до «Перечитать» (запасной путь, если хаб недоступен).
- «⋯ → Сменить токен хаба» — после пересоздания токенов.
```
cd ~/projects/awp-ca/jira-hub
git pull
make web
launchctl kickstart -k gui/$(id -u)/ru.aepif.jira-hub
```
Без `make web` хаб на `/app…` отвечает 503 с подсказкой. Раскладка доски и вход сохраняются: приложение
читает те же ключи `localStorage`, что прежняя страница. Разработка: `bash web/scripts/test-hub.sh` (тестовый хаб
:8791 с синтетическими данными) и `cd web && npm run dev` (http://127.0.0.1:5173/app/).
## Контракт команд

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -0,0 +1,195 @@
## 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 |
| R-15 (design #21) | 2026-10-14 | Ожидания теста `BoardCanvas` из плана выправлены по golden-эталону (4 дорожки, 9 карточек, 6 стрелок; заглушка 9500 и «Нет в зеркале» видны только при who=Иванов+neighbors; «Ниже» у Гудвилла даёт to=1); код компонентов — как в плане | эталон получен запуском настоящей `links.html@3931a88`, он — источник истины о поведении; цифры в плане были написаны по памяти | если эталон не отражает желаемого поведения — менять фикстуры и генератор, а не тест |
| R-16 (design #22) | 2026-10-14 | В `board.css` слой измерения получает `height:auto` (`.measure .card{position:static;height:auto}`), в отличие от CSS из плана | у `.card` фиксированные 120px; оригинал ставил `height:auto` перед измерением (links.html ~826), без этого авто-высота карточек не работает (I-1 ревью Task 11) | неверная раскладка высот; проверка реальной геометрии — e2e Task 15 |
| R-17 (design #23) | 2026-10-14 | Исполнитель Task 12 исправил в рамках задачи два дефекта прежних задач: `useBoardSource` отдаёт стабильную `emptyModel` через `useMemo` (иначе `useBoardPrefs` сбрасывает выбор при каждой новой модели → «Too many re-renders»), `shell/theme.ts readTheme` возвращает значение по умолчанию при пустом хранилище (иначе «Тема: undefined») | без правок страница доски не работала; отдельная задача ради двух строк — лишний круг | бесконечный рендер или «undefined» в меню темы; правки проверяются ревью Task 12 |
| R-18 (design #24) | 2026-10-14 | `setPointerCapture` в `BoardCanvas` ставится на нажатом элементе (`t`), а не на карточке (коммит d6fe046) — двойной щелчок по уголку `.rsz` снова сбрасывает размер | в Chromium capture на карточке отправляет `dblclick` на карточку; дефект воспроизведён и в старой `/links` (ширина 240→320→320), то есть унаследован; без правки e2e «растяжка…» недостижим | поведение перетаскивания/растяжки отличается от `links.html` в крайнем случае; откат — revert d6fe046 |

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,165 @@
## 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. Действия «Сменить токен» в меню
доски нет — оно в верхней панели приложения. Ещё два отличия от `links.html` (R-10, R-18): двойной щелчок по уголку
карточки меряет высоту по содержимому при новой ширине, а не при прежней; указатель при перетаскивании и растяжке
захватывается на нажатом элементе, а не на карточке, поэтому двойной щелчок по уголку сбрасывает размер. Загрузка файла 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` не показывает файлов сборки

View File

@ -1,6 +1,6 @@
"""HTTP API хаба (stdlib http.server, поток на запрос, соединение SQLite на запрос).
Маршруты /agent/* — агентский токен, остальные — клиентский (specs/hub-api/spec.md).
Страница доски (GET /, GET /links) — без токена: статический файл без данных."""
Без токена — статика без данных: веб-приложение (GET /app…) и перенаправление старых адресов доски (GET /, GET /links)."""
import hmac
import json
import logging
@ -11,7 +11,7 @@ import traceback
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import NamedTuple
from urllib.parse import parse_qs, urlparse
from urllib.parse import parse_qs, unquote, urlparse
from . import backup, db, mirror, queue, relay, release, settings
from .commands import CommandError
@ -22,14 +22,20 @@ from .queue import ClaimedError
log = logging.getLogger(__name__)
MAX_BODY = 16 * 1024 * 1024 # предел тела запроса, байт
PAGE = Path(__file__).parent / "web" / "links.html" # страница доски связей; читается на каждый запрос
WEB_APP = Path(__file__).parent / "web" / "app" # сборка веб-приложения (make web, не в git); читается на каждый запрос
ASSET_TYPES = {".js": "text/javascript; charset=utf-8", ".css": "text/css; charset=utf-8", ".svg": "image/svg+xml",
".png": "image/png", ".ico": "image/x-icon", ".woff2": "font/woff2", ".map": "application/json"}
ASSET_CACHE = {"Cache-Control": "public, max-age=31536000, immutable"} # имена файлов сборки содержат хэш (R-3)
NOT_BUILT = ("<!doctype html><meta charset=\"utf-8\"><title>Хаб Jira</title><p>Веб-приложение не собрано: выполните "
"<code>make web</code> в каталоге jira-hub и перезапустите хаб.</p>").encode("utf-8")
SOCKET_TIMEOUT = 60 # чтение запроса; ожидание долгого claim идёт уже после чтения тела и сокет не держит
class Raw(NamedTuple):
"""Не-JSON ответ обработчика (страница доски)."""
"""Не-JSON ответ обработчика (веб-приложение, перенаправление). headers=None — Cache-Control: no-cache."""
content_type: str
data: bytes
headers: dict | None = None
class ApiError(Exception):
@ -39,8 +45,9 @@ class ApiError(Exception):
class Hub:
def __init__(self, db_path, backups_dir=None, release_dir=None):
def __init__(self, db_path, backups_dir=None, release_dir=None, web_dir=None):
self.db_path = Path(db_path)
self.web_dir = Path(web_dir) if web_dir else WEB_APP
self.backups_dir = Path(backups_dir) if backups_dir else default_backups_dir()
self.release_dir = Path(release_dir) if release_dir else self.db_path.parent / "release"
self.agent_update = {"update_state": None, "launcher_version": None} # последнее из claim; в памяти (R-3)
@ -179,8 +186,25 @@ def h_board(hub, conn, m, q, body):
return 200, mirror.board(conn, settings.get(conn, "base_jql"))
def h_page(hub, conn, m, q, body):
return 200, Raw("text/html; charset=utf-8", PAGE.read_bytes())
def h_app(hub, conn, m, q, body):
"""Веб-приложение: файлы assets/ сборки как есть, любой другой путь /app… — index.html (маршруты на клиенте).
Путь файла после разрешения обязан лежать внутри assets/, иначе 404; без сборки — 503 с подсказкой."""
index = hub.web_dir / "index.html"
if not index.is_file():
return 503, Raw("text/html; charset=utf-8", NOT_BUILT)
rest = unquote(m.group(1) or "")
if rest.startswith("assets/"):
assets = (hub.web_dir / "assets").resolve()
f = (hub.web_dir / rest).resolve()
if not f.is_relative_to(assets) or not f.is_file():
raise ApiError(404, "not_found", "/app/" + rest)
return 200, Raw(ASSET_TYPES.get(f.suffix, "application/octet-stream"), f.read_bytes(), ASSET_CACHE)
return 200, Raw("text/html; charset=utf-8", index.read_bytes())
def h_root(hub, conn, m, q, body):
"""Старые адреса доски (/, /links) — на страницу доски веб-приложения."""
return 302, Raw("text/html; charset=utf-8", b"", {"Location": "/app/board", "Cache-Control": "no-cache"})
def h_note_put(hub, conn, m, q, body):
@ -294,7 +318,8 @@ def h_settings_put(hub, conn, m, q, body):
ROUTES = [
("GET", r"^/(links)?$", h_page, None),
("GET", r"^/(links)?$", h_root, None),
("GET", r"^/app(?:/(.*))?$", h_app, None),
("POST", r"^/agent/claim$", h_claim, "agent"),
("GET", r"^/agent/settings$", h_agent_settings, "agent"),
("GET", r"^/agent/commands/(\d+)$", h_agent_command, "agent"),
@ -388,7 +413,8 @@ class Handler(BaseHTTPRequestHandler):
def _send(self, status, payload):
if isinstance(payload, Raw):
ctype, data, extra = payload.content_type, payload.data, {"Cache-Control": "no-cache"}
ctype, data = payload.content_type, payload.data
extra = payload.headers if payload.headers is not None else {"Cache-Control": "no-cache"}
else:
ctype, extra = "application/json; charset=utf-8", {}
data = json.dumps(payload, ensure_ascii=False, default=str).encode("utf-8")

File diff suppressed because it is too large Load Diff

View File

@ -368,35 +368,6 @@ def test_board_route(hub):
assert e.value.status == 401
def _raw_get(base, path):
with urllib.request.urlopen(base + path, timeout=10) as resp:
return resp.status, resp.headers, resp.read().decode("utf-8")
def test_page_served_without_token(hub):
hub["agent"].post("/agent/state", {"issues": [{"key": "ZZTEST-9001", "summary": "тема из зеркала", "labels": []}],
"links": []})
for path in ("/", "/links"):
status, headers, body = _raw_get(hub["base"], path)
assert status == 200
assert headers["Content-Type"] == "text/html; charset=utf-8"
assert headers["Cache-Control"] == "no-cache"
assert body.lstrip().lower().startswith("<!doctype html>")
assert "ZZTEST-9001" not in body and "тема из зеркала" not in body
with pytest.raises(urllib.error.HTTPError) as e:
urllib.request.urlopen(hub["base"] + "/links/x", timeout=10)
assert e.value.code == 404
def test_page_reads_hub_and_has_no_embedded_export():
html = api.PAGE.read_text(encoding="utf-8")
assert "DEFAULT_CSV" not in html and "Ключ проблемы,Тема" not in html # встроенной выгрузки нет
assert '"jira-hub-token"' in html and 'hubFetch("/board")' in html
assert "Нет в зеркале" in html
assert 'author: "viewer"' in html and "/batches/" in html
assert "KEY_RE.test(l.source) && KEY_RE.test(l.target)" in html # I-1: ключи связей из зеркала идут в разметку карточек
def _publish(hub, tmp_path, version):
src = tmp_path / f"agent{version}.py"
src.write_text(f"AGENT_VERSION = {version}\nprint('v{version}')\n", encoding="utf-8")

View File

@ -180,3 +180,15 @@ def test_board_last_sync_is_last_successful_full_refresh():
_sync_run(conn, "2026-10-14T13:00:00+00:00", None, "jql", BASE, status="claimed", count=50)
assert mirror.board(conn, BASE)["last_sync"] == {"started_at": "2026-10-14T09:59:00+00:00",
"finished_at": "2026-10-14T10:00:00+00:00", "issues_count": 198}
def test_board_inside_open_transaction_keeps_it_open():
"""M-7 #20: вызов внутри уже открытой транзакции не открывает и не закрывает свою."""
conn = db.connect(":memory:")
mirror.apply_state(conn, [issue("AURORA-1620")], [])
with db.transaction(conn):
assert conn.in_transaction
snap = mirror.board(conn, BASE)
assert conn.in_transaction
assert not conn.in_transaction
assert [i["key"] for i in snap["issues"]] == ["AURORA-1620"]

113
tests/test_web_app.py Normal file
View File

@ -0,0 +1,113 @@
"""Раздача веб-приложения: GET /app… без токена (specs/hub-api «Раздача веб-приложения»)."""
import http.client
import threading
from urllib.parse import urlparse
import pytest
from jira_hub import api
def _get(base, path):
"""Сырой GET: http.client не нормализует путь (../ и %2e%2e доходят до хаба как есть)."""
u = urlparse(base)
c = http.client.HTTPConnection(u.hostname, u.port, timeout=10)
try:
c.request("GET", path)
r = c.getresponse()
return r.status, r, r.read()
finally:
c.close()
def _serve(tmp_path, web_dir):
h = api.Hub(tmp_path / "hub.sqlite", backups_dir=tmp_path / "backups", web_dir=web_dir)
srv = api.make_server(h, "127.0.0.1", 0)
th = threading.Thread(target=srv.serve_forever, daemon=True)
th.start()
return srv, th, f"http://127.0.0.1:{srv.server_port}"
@pytest.fixture
def app_hub(tmp_path):
web = tmp_path / "app"
(web / "assets").mkdir(parents=True)
(web / "index.html").write_text("<!doctype html><div id=root></div>", encoding="utf-8")
(web / "assets" / "index-abc123.js").write_text("console.log('ok')", encoding="utf-8")
(web / "assets" / "index-abc123.css").write_text("body{}", encoding="utf-8")
(web / "assets" / "font.bin").write_bytes(b"\x00\x01")
(tmp_path / "secret.txt").write_text("секрет", encoding="utf-8")
srv, th, base = _serve(tmp_path, web)
yield base
srv.shutdown()
srv.server_close()
th.join(5)
def test_asset_served_with_type_and_immutable_cache(app_hub):
status, r, body = _get(app_hub, "/app/assets/index-abc123.js")
assert status == 200
assert r.getheader("Content-Type") == "text/javascript; charset=utf-8"
assert r.getheader("Cache-Control") == "public, max-age=31536000, immutable"
assert body == b"console.log('ok')"
status, r, _ = _get(app_hub, "/app/assets/index-abc123.css")
assert r.getheader("Content-Type") == "text/css; charset=utf-8"
status, r, _ = _get(app_hub, "/app/assets/font.bin")
assert status == 200 and r.getheader("Content-Type") == "application/octet-stream"
@pytest.mark.parametrize("path", ["/app", "/app/", "/app/board", "/app/issues/AURORA-1620", "/app/issues?sprint=x"])
def test_app_paths_get_index_without_token(app_hub, path):
status, r, body = _get(app_hub, path)
assert status == 200
assert r.getheader("Content-Type") == "text/html; charset=utf-8"
assert r.getheader("Cache-Control") == "no-cache"
assert body == b"<!doctype html><div id=root></div>"
@pytest.mark.parametrize("path", ["/app/assets/../../secret.txt", "/app/assets/%2e%2e/%2e%2e/secret.txt",
"/app/assets/..%2F..%2Fsecret.txt", "/app/assets/missing.js", "/app/assets/"])
def test_asset_outside_or_missing_is_404(app_hub, path):
status, _, body = _get(app_hub, path)
assert status == 404
assert "секрет".encode("utf-8") not in body
def test_not_built_is_503_with_hint(tmp_path):
srv, th, base = _serve(tmp_path, tmp_path / "no-build")
try:
for path in ("/app/board", "/app/assets/index-abc123.js"):
status, r, body = _get(base, path)
assert status == 503
assert r.getheader("Content-Type") == "text/html; charset=utf-8"
assert "make web".encode("utf-8") in body
finally:
srv.shutdown()
srv.server_close()
th.join(5)
def test_other_paths_are_not_app(app_hub):
status, _, _ = _get(app_hub, "/application")
assert status == 404 # не префикс /app/, обычный 404 API
status, _, _ = _get(app_hub, "/board")
assert status == 401 # API по-прежнему под токеном
def test_default_web_dir_is_package_build(tmp_path):
h = api.Hub(tmp_path / "hub.sqlite", backups_dir=tmp_path / "backups")
assert h.web_dir == api.WEB_APP
assert api.WEB_APP.parts[-3:] == ("jira_hub", "web", "app")
@pytest.mark.parametrize("path", ["/", "/links"])
def test_old_board_addresses_redirect_to_app(app_hub, path):
status, r, body = _get(app_hub, path)
assert status == 302
assert r.getheader("Location") == "/app/board"
assert body == b""
def test_links_page_is_gone():
assert not hasattr(api, "PAGE")
assert not (api.WEB_APP.parent / "links.html").exists()

59
web/e2e/board.spec.ts Normal file
View File

@ -0,0 +1,59 @@
import { expect, test } from "@playwright/test";
import { agentServeRefresh, login } from "./hub.ts";
test.beforeEach(async ({ page }) => { await login(page); });
test("перетаскивание за рамку двигает карточку и переживает перезагрузку", async ({ page }) => {
const card = page.locator('.board .card[data-key="AURORA-9001"]');
const before = await card.boundingBox();
const grip = card.locator(".grip.t");
const g = (await grip.boundingBox())!;
await page.mouse.move(g.x + g.width / 2, g.y + g.height / 2);
await page.mouse.down();
await page.mouse.move(g.x + g.width / 2 + 160, g.y + g.height / 2 + 40, { steps: 8 });
await page.mouse.up();
const after = (await card.boundingBox())!;
expect(Math.round(after.x - before!.x)).toBeGreaterThan(100);
await page.reload();
const reloaded = (await page.locator('.board .card[data-key="AURORA-9001"]').boundingBox())!;
expect(Math.abs(reloaded.x - after.x)).toBeLessThan(2);
expect(Math.abs(reloaded.y - after.y)).toBeLessThan(2);
});
test("растяжка за уголок и возврат размера двойным щелчком", async ({ page }) => {
const card = page.locator('.board .card[data-key="AURORA-9002"]');
const rsz = (await card.locator(".rsz").boundingBox())!;
const w0 = (await card.boundingBox())!.width;
await page.mouse.move(rsz.x + 2, rsz.y + 2);
await page.mouse.down();
await page.mouse.move(rsz.x + 82, rsz.y + 42, { steps: 6 });
await page.mouse.up();
expect((await card.boundingBox())!.width).toBeGreaterThan(w0 + 60);
await card.locator(".rsz").dblclick();
expect(Math.round((await card.boundingBox())!.width)).toBe(240);
});
test("«по датам» — колонки дат, подписи и стрелки", async ({ page }) => {
await page.getByRole("button", { name: "▥ по датам" }).click();
await expect(page.locator(".dcol").first()).toBeVisible();
await expect(page.locator(".dates-track .dlabel").first()).toBeVisible();
await expect(page.locator("svg.edges path")).not.toHaveCount(0);
});
test("«Обновить из Jira» с учебным агентом доходит до конца", async ({ page }) => {
const btn = page.getByRole("button", { name: "Обновить из Jira" });
await btn.click();
await expect(btn).toBeDisabled();
await expect(page.getByRole("status")).toContainText(/пакет #\d+/);
await agentServeRefresh();
await expect(page.getByRole("status")).toContainText(/пакет #\d+ выполнен/, { timeout: 15_000 });
await expect(btn).toBeEnabled();
await expect(page.getByText(/^Хаб · данные на /)).toBeVisible();
await expect(page.locator(".board .card[data-key]")).not.toHaveCount(0);
});
test("ⓘ открывает карточку задачи в приложении", async ({ page }) => {
await page.locator('.board .card[data-key="AURORA-9001"]').getByRole("link", { name: "Открыть карточку AURORA-9001" }).click();
await expect(page).toHaveURL(/\/app\/issues\/AURORA-9001$/);
await expect(page.getByRole("heading", { name: /AURORA-9001/ })).toBeVisible();
});

81
web/e2e/geometry.spec.ts Normal file
View File

@ -0,0 +1,81 @@
import { expect, test, type Page } from "@playwright/test";
import { login } from "./hub.ts";
// то, что в jsdom не проверить: реальная геометрия, ширины и поведение при смене фильтров
function watchErrors(page: Page): string[] {
const errs: string[] = [];
page.on("pageerror", (e) => errs.push("pageerror: " + e.message));
page.on("console", (m) => { if (m.type() === "error") errs.push("console: " + m.text()); });
return errs;
}
test("высота карточек берётся по содержимому из слоя измерения, а не 120px", async ({ page }) => {
await login(page);
await expect(page.locator(".board .card[data-key]").first()).toBeVisible();
const rows = await page.evaluate(() => {
const m = new Map<string, number>();
document.querySelectorAll<HTMLElement>(".measure .card[data-key]").forEach((e) => m.set(e.dataset.key!, e.offsetHeight));
return [...document.querySelectorAll<HTMLElement>(".board .card[data-key]")].map((e) => ({
key: e.dataset.key!, h: parseFloat(e.style.height), measured: m.get(e.dataset.key!) ?? -1,
measureStyleHeight: getComputedStyle(document.querySelector(`.measure .card[data-key="${e.dataset.key}"]`)!).height,
}));
});
expect(rows.length).toBeGreaterThan(0);
for (const r of rows) {
expect(r.measured, r.key).toBeGreaterThan(0);
expect(r.h, r.key).toBe(Math.max(80, Math.ceil(r.measured / 8) * 8)); // measuredHeight: сетка 8, не меньше MINH
}
// слой измерения не зажат в 120px: хотя бы одна карточка разной высоты с другой или не равна 120
expect(rows.some((r) => r.measured !== 120) || new Set(rows.map((r) => r.measured)).size > 1).toBe(true);
});
test("при открытой панели «Проблемы» полоса дат по ширине равна обёртке холста", async ({ page }) => {
await login(page);
await page.getByRole("button", { name: "▥ по датам" }).click();
await expect(page.locator(".dates-in")).toBeVisible();
for (const open of [false, true]) {
if (open) {
await page.getByRole("button", { name: /^Проблемы/ }).click();
await expect(page.locator(".drawer")).toBeVisible();
}
await expect.poll(async () => {
const a = (await page.locator(".dates-in").boundingBox())!.width, b = (await page.locator(".stage .wrap").boundingBox())!.width;
return Math.abs(a - b);
}, `панель ${open ? "открыта" : "закрыта"}`).toBeLessThanOrEqual(1);
}
});
test("смена спринта и исполнителя, убирающая карточки, не даёт ошибок и белого экрана", async ({ page }) => {
const errs = watchErrors(page);
await login(page);
await expect(page.locator(".board .card[data-key]").first()).toBeVisible();
const total = await page.locator(".board .card[data-key]").count();
const dd = (i: number) => page.locator(".bar .dd-btn").nth(i); // 0 — спринты, 1 — исполнители
for (const [i, name] of [[0, "Спринт 3"], [1, "Сидорова"]] as const) {
await dd(i).click();
await page.getByRole("button", { name: "Снять выбор" }).click();
await page.locator(".dd-item", { hasText: name }).first().locator("input").check();
await page.keyboard.press("Escape");
await expect(page.locator("h1")).toHaveText("Доска связей");
await expect(page.locator(".top")).toBeVisible();
await expect(page.locator(".board")).toBeVisible();
await expect.poll(() => page.locator(".board .card[data-key]:not(.dim)").count(), `фильтр «${name}»`).toBeLessThan(total);
await dd(i).click();
await page.getByRole("button", { name: "Снять выбор" }).click();
await page.keyboard.press("Escape");
}
await page.getByRole("button", { name: "▥ по датам" }).click();
await expect(page.locator(".top")).toBeVisible();
expect(errs).toEqual([]);
});
test("h1 на страницах доски, задач и карточки", async ({ page }) => {
await login(page);
await expect(page.locator("h1")).toHaveText("Доска связей");
await page.getByRole("link", { name: "Задачи" }).click();
await expect(page.getByRole("heading", { level: 1, name: "Задачи" })).toBeVisible();
await page.getByRole("link", { name: "AURORA-9002" }).click();
await expect(page.getByRole("heading", { level: 1, name: /AURORA-9002/ })).toBeVisible();
await expect(page.locator("h1")).toHaveCount(1);
});

28
web/e2e/hub.ts Normal file
View File

@ -0,0 +1,28 @@
import { expect, type Page } from "@playwright/test";
import { readFileSync } from "node:fs";
import { HUB_HOME } from "../playwright.config.ts";
export const tokens = () => JSON.parse(readFileSync(`${HUB_HOME}/tokens.json`, "utf8")) as { agent: string; client: string };
const BASE = "http://127.0.0.1:8791";
export async function login(page: Page, path = "/app/board") {
await page.goto(path);
await page.getByLabel("Клиентский токен хаба").fill(tokens().client);
await page.getByRole("button", { name: "Войти" }).click();
await expect(page.getByRole("link", { name: "Доска" })).toBeVisible();
}
async function call(path: string, body: unknown) {
const r = await fetch(BASE + path, { method: "POST", headers: { Authorization: "Bearer " + tokens().agent, "Content-Type": "application/json" }, body: JSON.stringify(body) });
expect(r.ok, `${path} → ${r.status}`).toBe(true);
return r.json();
}
// учебный агент: взять команду refresh, загрузить состояние зеркала как есть и отчитаться done
export async function agentServeRefresh() {
const { command } = await call("/agent/claim", { agent_version: 99, settings_version: null, last_jira_call_at: null });
expect(command?.type).toBe("refresh");
await call("/agent/state", { command_id: command.id, issues: [], links: [], neighbor_keys: [] });
await call(`/agent/commands/${command.id}/result`, { status: "done", result: { issues: 0 }, jira_calls: 1, rate_limited: 0 });
return command.id as number;
}

15
web/e2e/issues.spec.ts Normal file
View File

@ -0,0 +1,15 @@
import { expect, test } from "@playwright/test";
import { login } from "./hub.ts";
test("фильтр → карточка → заметка сохраняется", async ({ page }) => {
await login(page, "/app/issues");
await page.getByLabel("Исполнитель").selectOption("Петров Пётр Петрович");
await expect(page).toHaveURL(/assignee=/);
await page.getByRole("link", { name: "AURORA-9002" }).click();
const rest = page.getByLabel("Осталось");
await rest.fill("e2e: дописать тесты");
await page.getByRole("button", { name: "Сохранить" }).click();
await expect(page.getByText(/сохранено .*web/)).toBeVisible();
await page.reload();
await expect(page.getByLabel("Осталось")).toHaveValue("e2e: дописать тесты");
});

17
web/e2e/login.spec.ts Normal file
View File

@ -0,0 +1,17 @@
import { expect, test } from "@playwright/test";
import { login } from "./hub.ts";
test("неверный токен — пояснение; верный — доска", async ({ page }) => {
await page.goto("/app/board");
await page.getByLabel("Клиентский токен хаба").fill("wrong-token");
await page.getByRole("button", { name: "Войти" }).click();
await expect(page.getByText(/Токен не подошёл/)).toBeVisible();
await login(page);
await expect(page.locator(".card[data-key]").first()).toBeVisible();
});
test("/ и /app ведут на доску", async ({ page }) => {
await login(page);
await page.goto("/app");
await expect(page).toHaveURL(/\/app\/board$/);
});

16
web/eslint.config.js Normal file
View File

@ -0,0 +1,16 @@
import js from "@eslint/js";
import reactHooks from "eslint-plugin-react-hooks";
import globals from "globals";
import tseslint from "typescript-eslint";
export default tseslint.config(
{ ignores: ["node_modules", "playwright-report", "test-results", ".e2e-hub"] },
js.configs.recommended,
...tseslint.configs.recommended,
{
files: ["**/*.{ts,tsx,js}"],
languageOptions: { globals: { ...globals.browser, ...globals.node } },
plugins: { "react-hooks": reactHooks },
rules: { "react-hooks/rules-of-hooks": "error", "react-hooks/exhaustive-deps": "error" },
},
);

14
web/index.html Normal file
View File

@ -0,0 +1,14 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Хаб Jira</title>
<link rel="icon" href="data:,">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@500&display=swap">
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3892
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

39
web/package.json Normal file
View File

@ -0,0 +1,39 @@
{
"name": "jira-hub-web",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit -p . && vite build",
"lint": "tsc --noEmit -p . && eslint .",
"test": "vitest run",
"e2e": "playwright test",
"golden": "node scripts/golden-from-links.ts"
},
"dependencies": {
"@tanstack/react-query": "5.104.0",
"react": "19.3.0",
"react-dom": "19.3.0",
"react-router": "8.4.0"
},
"devDependencies": {
"@eslint/js": "10.0.1",
"@playwright/test": "1.63.0",
"@testing-library/dom": "10.4.2",
"@testing-library/jest-dom": "7.0.1",
"@testing-library/react": "16.3.3",
"@testing-library/user-event": "14.6.7",
"@types/node": "26.6.3",
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"@vitejs/plugin-react": "6.1.1",
"eslint": "10.11.0",
"eslint-plugin-react-hooks": "7.1.1",
"globals": "17.12.0",
"jsdom": "29.1.1",
"typescript": "6.0.3",
"typescript-eslint": "8.71.0",
"vite": "8.3.1",
"vitest": "5.0.2"
}
}

18
web/playwright.config.ts Normal file
View File

@ -0,0 +1,18 @@
import { defineConfig } from "@playwright/test";
import { resolve } from "node:path";
export const HUB_HOME = resolve(import.meta.dirname, ".e2e-hub");
export default defineConfig({
testDir: "e2e",
workers: 1,
timeout: 60_000,
use: { baseURL: "http://127.0.0.1:8791", trace: "retain-on-failure", viewport: { width: 1600, height: 1000 } },
webServer: {
command: "npm run build && bash scripts/test-hub.sh",
url: "http://127.0.0.1:8791/app/",
reuseExistingServer: false,
timeout: 120_000,
env: { JIRA_HUB_HOME: HUB_HOME, PORT: "8791" },
},
});

View File

@ -0,0 +1,195 @@
// разовый генератор golden-эталонов доски: исполняет <script> страницы links.html ревизии 3931a88 в node:vm с заглушкой
// DOM и записывает результаты её функций на фикстурах в src/board/__fixtures__/*.expected.json.
// Запуск: cd web && npm run golden (Node 24 исполняет .ts без сборки). Эталоны коммитятся; скрипт перезапускать только
// при смене фикстур.
import { execFileSync } from "node:child_process";
import { readFileSync, writeFileSync } from "node:fs";
import vm from "node:vm";
import { canon } from "../src/test/canon.ts";
const REV = "3931a88";
const NOW = "2026-10-14T12:00:00.000Z";
const FIX = new URL("../src/board/__fixtures__/", import.meta.url);
const read = (name: string) => readFileSync(new URL(name, FIX), "utf8");
const write = (name: string, v: unknown) => writeFileSync(new URL(name, FIX), JSON.stringify(canon(v), null, 1) + "\n");
const html = execFileSync("git", ["show", `${REV}:src/jira_hub/web/links.html`], { encoding: "utf8" });
const page = html.slice(html.indexOf("<script>") + "<script>".length, html.lastIndexOf("</script>"));
// элемент-заглушка: хранит стили, атрибуты и детей; querySelector возвращает один и тот же элемент на селектор;
// высота карточки 117 px, её заголовка (.title) 38 px, остальных элементов 0
class El {
className = ""; hidden = false; disabled = false; title = ""; value = ""; textContent = ""; open = false; scrollLeft = 0;
style: Record<string, unknown> = {
setProperty(k: string, v: string) { this[k] = v; },
removeProperty(k: string) { delete this[k]; },
};
dataset: Record<string, string> = {};
attrs: Record<string, unknown> = {};
children: El[] = [];
sel = "";
private html = "";
private q = new Map<string, El>();
classList = { add() {}, remove() {}, toggle() {}, contains: () => false };
get innerHTML() { return this.html; }
set innerHTML(v: string) { this.html = v; this.children = []; }
get offsetHeight() { return this.sel === ".title" ? 38 : this.className.startsWith("card") ? 117 : 0; }
get offsetWidth() { return 0; }
get scrollWidth() { return 0; }
get clientWidth() { return 0; }
appendChild(c: El) { this.children.push(c); return c; }
querySelector(s: string) { let e = this.q.get(s); if (!e) { e = new El(); e.sel = s; this.q.set(s, e); } return e; }
// две заглушки: страница деструктурирует кнопки «Выше»/«Ниже» у дорожки
querySelectorAll() { return [new El(), new El()]; }
setAttribute(k: string, v: unknown) { this.attrs[k] = v; }
getAttribute(k: string) { return this.attrs[k] ?? null; }
removeAttribute(k: string) { delete this.attrs[k]; }
addEventListener() {} removeEventListener() {} setPointerCapture() {} focus() {} select() {} click() {} remove() {}
contains() { return false; }
closest() { return null; }
getBoundingClientRect() { return { top: 0, left: 0, width: 0, height: 0 }; }
}
const byId = new Map<string, El>(), bySel = new Map<string, El>();
const document = {
getElementById: (id: string) => { let e = byId.get(id); if (!e) { e = new El(); byId.set(id, e); } return e; },
querySelector: (s: string) => { let e = bySel.get(s); if (!e) { e = new El(); bySel.set(s, e); } return e; },
querySelectorAll: () => [],
createElement: () => new El(),
createElementNS: () => new El(),
addEventListener() {},
documentElement: new El(),
body: new El(),
};
const data = new Map<string, string>();
const localStorage = {
getItem: (k: string) => (data.has(k) ? data.get(k)! : null),
setItem: (k: string, v: unknown) => { data.set(k, String(v)); },
removeItem: (k: string) => { data.delete(k); },
clear: () => data.clear(),
key: (i: number) => [...data.keys()][i] ?? null,
get length() { return data.size; },
};
const sandbox: Record<string, unknown> = {
document, localStorage, console, setTimeout, clearTimeout, URL, Blob, navigator: {},
requestAnimationFrame: (f: () => void) => setTimeout(f, 0),
getComputedStyle: () => ({ position: "static" }),
fetch: () => Promise.reject(new Error("нет сети")),
addEventListener() {}, removeEventListener() {},
};
const ctx = vm.createContext(sandbox);
sandbox.window = vm.runInContext("globalThis", ctx);
// фиксированное «сегодня»: findProblems, колонки дат и exportedAt зависят от new Date()
vm.runInContext(`{ const R = Date; Date = class extends R { constructor(...a) { super(...(a.length ? a : ["${NOW}"])); }
static now() { return R.parse("${NOW}"); } }; }`, ctx);
vm.runInContext(page, ctx, { filename: "links.html@" + REV });
// доступ к функциям и состоянию страницы (let/const скрипта видны следующим скриптам того же контекста)
vm.runInContext(String.raw`
function dump() { const o = {}; for (let i = 0; i < localStorage.length; i++) { const k = localStorage.key(i); o[k] = localStorage.getItem(k); } return o; }
function reset() {
localStorage.clear();
Object.assign(S, { neighbors: false, onlyLinked: false, onlyHand: false, crit: false, byDate: false, snap: true,
sprints: new Set(), who: new Set(), hideKind: new Set(), hideEdge: new Set() });
}
globalThis.__g = {
dicts: () => ({ kindLabels: KIND_LABELS, linkTypes: LINK_TYPES }),
fromHub: (s) => fromHub(s),
fromCsv: (t, n) => fromJiraCsv(t, n),
parseCsv: (t) => parseCsv(t),
fromJson: (t, n) => fromJson(t, n),
facets(model, remembered) {
reset(); MODEL = model;
if (remembered !== undefined) localStorage.setItem(fileKey("sprint"), JSON.stringify(remembered));
fillSprints(); fillWho();
return { sprintItems: sprintItems().items, whoItems: whoItems().items, sprints: S.sprints, who: S.who };
},
scenario(model, sc) {
reset(); MODEL = model;
const f = sc.filters || {};
for (const k of ["neighbors", "onlyLinked", "onlyHand", "crit", "byDate"]) S[k] = !!f[k];
for (const k of ["sprints", "who", "hideKind", "hideEdge"]) S[k] = new Set(f[k] || []);
if (sc.laneOrder) localStorage.setItem(fileKey("lanes"), JSON.stringify(sc.laneOrder));
if (sc.folded) localStorage.setItem(fileKey("fold"), JSON.stringify(sc.folded));
if (sc.saved) localStorage.setItem(fileKey("pos"), JSON.stringify(sc.saved));
if (sc.savedDate) localStorage.setItem(fileKey("posdate"), JSON.stringify(sc.savedDate));
if (sc.colWidths) localStorage.setItem(fileKey("colw"), JSON.stringify(sc.colWidths));
render();
const clamp = {};
Object.keys(cardEls).forEach((k) => { clamp[k] = cardEls[k].querySelector(".title").style.webkitLineClamp; });
return {
visible: VIS,
emptyReason: VIS.nodes.length ? null : emptyReason(),
layout: {
rel, laneOf, colBounds, baseW, pos, clamp,
lanes: lanesGeom.map((g) => ({ name: g.name, y: g.y, h: g.h, minH: g.minH, folded: g.folded })),
board: { width: board.style.width, height: board.style.height },
dateCols: dateEls.map((e) => ({ className: e.className, left: e.style.left, width: e.style.width })),
dateLabels: $("datesTrack").children.filter((e) => e.className.startsWith("dlabel"))
.map((e) => ({ className: e.className, left: e.style.left, title: e.title, innerHTML: e.innerHTML })),
edges: VIS.links.filter((l) => pos[l.src] && pos[l.dst])
.map((l) => ({ src: l.src, dst: l.dst, type: l.type, hand: l.hand, crit: l.crit, d: edgePath(pos[l.src], pos[l.dst]) })),
},
};
},
exportAfter(model, sc) { this.scenario(model, sc); return exportJson(); },
importJson(text, name) {
reset();
const r = fromJson(text, name);
applyJsonSettings(r);
return { import: r, storage: dump(), S: { neighbors: S.neighbors, onlyLinked: S.onlyLinked, onlyHand: S.onlyHand,
crit: S.crit, byDate: S.byDate, hideKind: S.hideKind, hideEdge: S.hideEdge } };
},
storage(before, op) {
localStorage.clear();
Object.entries(before).forEach(([k, v]) => localStorage.setItem(k, v));
if (op === "startup") {
const old = store.get(pref("mode"), null);
if (old !== null) { if (old === "linked") store.set(pref("neighbors"), "1"); if (old === "all") store.set(fileKey("sprint"), "[]"); store.del(pref("mode")); }
}
migrateStore();
return dump();
},
edgePath: (a, b) => edgePath(a, b),
orthoPath: (pts) => orthoPath(pts),
align(posIn, key, r, resizing) {
pos = posIn; guidesSvg.innerHTML = "";
const out = alignEdges(key, r, resizing);
return { out, guides: guidesSvg.children.map((l) => l.attrs) };
},
};`, ctx);
type G = Record<string, (...a: unknown[]) => unknown>;
const g = vm.runInContext("__g", ctx) as G;
const scenarios = JSON.parse(read("scenarios.json")) as { name: string }[];
const basic = g.fromHub(JSON.parse(read("hub-basic.board.json")));
write("hub-basic.expected.json", {
dicts: g.dicts(),
model: basic,
facets: { default: g.facets(basic), rememberedAll: g.facets(basic, []), rememberedSome: g.facets(basic, ["AWPCA_Спринт 3", "нет такого"]) },
scenarios: Object.fromEntries(scenarios.map((sc) => [sc.name, g.scenario(basic, sc)])),
export: g.exportAfter(basic, scenarios[0]),
});
const empty = g.fromHub(JSON.parse(read("hub-empty.board.json")));
write("hub-empty.expected.json", { model: empty, scenario: g.scenario(empty, scenarios[0]) });
const csv = read("jira-export.csv");
write("jira-export.expected.json", { rows: g.parseCsv(csv), model: g.fromCsv(csv, "jira-export.csv") });
write("jira-no-links.expected.json", { model: g.fromCsv(read("jira-no-links.csv"), "jira-no-links.csv") });
let bad: string | null = null;
try { g.fromCsv(read("jira-bad.csv"), "jira-bad.csv"); } catch (e) { bad = (e as Error).message; }
write("jira-bad.expected.json", { error: bad });
write("old-board.expected.json", g.importJson(read("old-board.json"), "old-board.json"));
const geo = JSON.parse(read("geometry.cases.json")) as {
edge: { a: unknown; b: unknown }[]; ortho: unknown[]; align: { pos: unknown; key: string; r: unknown; resizing: boolean }[];
};
write("geometry.expected.json", {
edge: geo.edge.map((c) => g.edgePath(c.a, c.b)),
ortho: geo.ortho.map((p) => g.orthoPath(p)),
align: geo.align.map((c) => g.align(c.pos, c.key, c.r, c.resizing)),
});
const cases = JSON.parse(read("storage.cases.json")) as { name: string; op: string; before: unknown }[];
write("storage.expected.json", Object.fromEntries(cases.map((c) => [c.name, g.storage(c.before, c.op)])));
console.log("эталоны записаны в", FIX.pathname);

21
web/scripts/seed-hub.py Normal file
View File

@ -0,0 +1,21 @@
"""Заполнить зеркало тестового хаба фикстурой доски и записать токены рядом с базой (для e2e и npm run dev).
Пишет в базу напрямую (mirror.apply_state, set_note), до запуска serve: гонки с первым запросом нет."""
import json
import sys
from pathlib import Path
from jira_hub import db, mirror, paths, settings
snap = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
conn = db.connect(paths.db_path())
settings.ensure_defaults(conn)
issues = [{k: v for k, v in i.items() if k not in ("fact_status", "work_kind", "neighbor")} | {"id": i["key"]} for i in snap["issues"]]
links = [{"source_key": l["source"], "target_key": l["target"], "link_type": l["link_type"]} for l in snap["links"]]
mirror.apply_state(conn, issues, links, neighbor_keys=[i["key"] for i in snap["issues"] if i["neighbor"]])
for i in snap["issues"]:
note = {k: i[k] for k in ("fact_status", "work_kind") if i.get(k)}
if note:
mirror.set_note(conn, i["key"], note, "seed")
(paths.home() / "tokens.json").write_text(json.dumps(settings.tokens(conn)), encoding="utf-8")
conn.close()
print("зеркало:", len(issues), "задач,", len(links), "связей; токены —", paths.home() / "tokens.json")

20
web/scripts/test-hub.sh Normal file
View File

@ -0,0 +1,20 @@
#!/usr/bin/env bash
# Тестовый хаб для разработки фронта и e2e: свой JIRA_HUB_HOME (по умолчанию web/.e2e-hub, очищается), порт 8791,
# зеркало — фикстура hub-basic. Живой хаб (:8790, ~/.local/share/jira-hub) не трогается.
set -euo pipefail
WEB=$(cd "$(dirname "$0")/.." && pwd)
ROOT=$(cd "$WEB/.." && pwd)
export JIRA_HUB_HOME="${JIRA_HUB_HOME:-$WEB/.e2e-hub}"
PORT="${PORT:-8791}"
case "$JIRA_HUB_HOME" in "$HOME/.local/share/jira-hub"*) echo "отказ: это каталог живого хаба" >&2; exit 2;; esac
# чистим только каталог внутри web/.e2e-hub (T15.5): чужой путь из переменной окружения не удаляем
case "$JIRA_HUB_HOME" in
*..*) echo "отказ: JIRA_HUB_HOME=$JIRA_HUB_HOME содержит «..», очистка не выполняется" >&2; exit 2;; # F-1
"$WEB/.e2e-hub"|"$WEB/.e2e-hub/"*) ;;
*) echo "отказ: JIRA_HUB_HOME=$JIRA_HUB_HOME вне $WEB/.e2e-hub, очистка не выполняется" >&2; exit 2;;
esac
rm -rf "$JIRA_HUB_HOME"
cd "$ROOT"
uv run jira-hub init --config tests/fixtures/config.example.json --force >/dev/null
uv run python web/scripts/seed-hub.py web/src/board/__fixtures__/hub-basic.board.json
exec uv run jira-hub serve --host 127.0.0.1 --port "$PORT"

56
web/src/App.test.tsx Normal file
View File

@ -0,0 +1,56 @@
import { screen } from "@testing-library/react";
import { render } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { createMemoryRouter } from "react-router";
import { beforeEach, describe, expect, it } from "vitest";
import { App } from "./App.tsx";
import { clearToken, getAuth, setToken } from "./auth/token.ts";
import { routes } from "./routes.tsx";
import { json, mockFetch } from "./test/fetchMock.ts";
const at = (path: string) => createMemoryRouter(routes, { initialEntries: [path], basename: "/app" });
describe("оболочка", () => {
beforeEach(() => { setToken("tok"); mockFetch(() => json(200, {})); });
it("/app ведёт на доску", async () => {
render(<App router={at("/app")} />);
expect(await screen.findByTestId("page-board")).toBeInTheDocument();
});
it("переход «Задачи» и обратно", async () => {
const router = at("/app/board");
render(<App router={router} />);
await userEvent.click(screen.getByRole("link", { name: "Задачи" }));
expect(router.state.location.pathname).toBe("/app/issues");
expect(await screen.findByTestId("page-issues")).toBeInTheDocument();
});
it("неизвестный путь — «не найдено» со ссылками", async () => {
render(<App router={at("/app/nope")} />);
expect(await screen.findByRole("heading", { name: "Страница не найдена" })).toBeInTheDocument();
});
it("«Сменить токен» показывает экран входа", async () => {
render(<App router={at("/app/board")} />);
await userEvent.click(screen.getByRole("button", { name: "Сменить токен" }));
expect(getAuth().token).toBe("");
expect(screen.getByRole("heading", { name: "Вход в хаб" })).toBeInTheDocument();
});
it("исключение в странице — сообщение и «Перезагрузить», панель на месте", async () => {
const Boom = () => { throw new Error("сломалось"); };
const router = createMemoryRouter([{ path: "/", element: <App.Layout />, children: [{ path: "x", element: <Boom /> }] }],
{ initialEntries: ["/x"] });
render(<App router={router} />);
expect(await screen.findByText(/сломалось/)).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Перезагрузить" })).toBeInTheDocument();
expect(screen.getByRole("link", { name: "Доска" })).toBeInTheDocument();
});
it("без токена — только экран входа", () => {
clearToken();
render(<App router={at("/app/board")} />);
expect(screen.queryByRole("link", { name: "Доска" })).not.toBeInTheDocument();
});
});

23
web/src/App.tsx Normal file
View File

@ -0,0 +1,23 @@
import { QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
import { createBrowserRouter, RouterProvider } from "react-router";
import { makeQueryClient } from "./api/queryClient.ts";
import { AuthGate } from "./auth/AuthGate.tsx";
import { routes } from "./routes.tsx";
import { Layout } from "./shell/Layout.tsx";
import "./styles/theme.css";
import "./styles/base.css";
import "./styles/controls.css";
type Router = ReturnType<typeof createBrowserRouter>;
export function App({ router }: { router?: Router }) {
const [client] = useState(makeQueryClient);
const [r] = useState(() => router ?? createBrowserRouter(routes, { basename: "/app" }));
return (
<QueryClientProvider client={client}>
<AuthGate><RouterProvider router={r} /></AuthGate>
</QueryClientProvider>
);
}
App.Layout = Layout;

View File

@ -0,0 +1,57 @@
import { beforeEach, describe, expect, it } from "vitest";
import { clearToken, setToken } from "../auth/token.ts";
import { json, mockFetch } from "../test/fetchMock.ts";
import { api, ApiError, AuthError, checkToken, HubDownError } from "./client.ts";
describe("api", () => {
beforeEach(() => setToken("tok"));
it("шлёт токен и параметры, пустые параметры выбрасывает", async () => {
const fetch = mockFetch(() => json(200, { issues: [] }));
await api.get("/issues", { sprint: "S 2", q: "", epic: null, limit: 50 });
const [url, init] = fetch.mock.calls[0];
expect(url).toBe("/issues?sprint=S+2&limit=50");
expect((init!.headers as Record<string, string>).Authorization).toBe("Bearer tok");
expect(init!.cache).toBe("no-store");
});
it("PUT отправляет JSON", async () => {
const fetch = mockFetch((_m, _u, body) => json(200, body));
expect(await api.put("/issues/A-1/note", { rest_text: "x" })).toEqual({ rest_text: "x" });
expect((fetch.mock.calls[0][1]!.headers as Record<string, string>)["Content-Type"]).toBe("application/json; charset=utf-8");
});
it("401 — AuthError, 5xx и сеть — HubDownError, прочие 4xx — ApiError с detail", async () => {
mockFetch(() => json(401, { error: "unauthorized" }));
await expect(api.get("/board")).rejects.toBeInstanceOf(AuthError);
mockFetch(() => json(503, {}));
await expect(api.get("/board")).rejects.toBeInstanceOf(HubDownError);
mockFetch(() => { throw new TypeError("Failed to fetch"); });
await expect(api.get("/board")).rejects.toBeInstanceOf(HubDownError);
mockFetch(() => json(422, { error: "bad_note", detail: "неизвестные поля заметки: x" }));
const e = await api.put("/issues/A-1/note", { x: 1 }).catch((err: unknown) => err);
expect(e).toBeInstanceOf(ApiError);
expect(e).toMatchObject({ status: 422, error: "bad_note", detail: "неизвестные поля заметки: x" });
});
it("без токена — AuthError без запроса", async () => {
clearToken();
const fetch = mockFetch(() => json(200, {}));
await expect(api.get("/board")).rejects.toBeInstanceOf(AuthError);
expect(fetch).not.toHaveBeenCalled();
});
it("токен с недопустимыми символами — AuthError без запроса", async () => {
setToken("ток ен");
const fetch = mockFetch(() => json(200, {}));
await expect(api.get("/board")).rejects.toBeInstanceOf(AuthError);
expect(fetch).not.toHaveBeenCalled();
});
it("checkToken проверяет переданный токен, не сохранённый", async () => {
const fetch = mockFetch(() => json(200, { kind_labels: {} }));
await checkToken("candidate");
expect((fetch.mock.calls[0][1]!.headers as Record<string, string>).Authorization).toBe("Bearer candidate");
expect(fetch.mock.calls[0][0]).toBe("/settings");
});
});

60
web/src/api/client.ts Normal file
View File

@ -0,0 +1,60 @@
// единственный клиент HTTP API хаба: тот же хост, клиентский токен в Authorization, ответы — JSON.
// Ошибки: AuthError — 401 или нет токена; HubDownError — сеть или 5xx; ApiError — прочие 4xx с {error, detail}
import { getToken, TOKEN_RE } from "../auth/token.ts";
import type { HubSettings } from "./types.ts";
export class AuthError extends Error {}
export class HubDownError extends Error {}
export class ApiError extends Error {
readonly status: number;
readonly error: string;
readonly detail: string | null;
constructor(status: number, error: string, detail: string | null) {
super(detail ? `${error}: ${detail}` : error);
this.status = status;
this.error = error;
this.detail = detail;
}
}
export type Params = Record<string, string | number | boolean | null | undefined>;
function withParams(path: string, params?: Params): string {
if (!params) return path;
const q = new URLSearchParams();
for (const [k, v] of Object.entries(params)) if (v !== null && v !== undefined && v !== "") q.set(k, String(v));
const s = q.toString();
return s ? `${path}?${s}` : path;
}
async function call<T>(method: string, path: string, body?: unknown, token = getToken()): Promise<T> {
if (!token) throw new AuthError("нет токена");
// токен с недопустимыми символами (например, оставленный прежней доской) — сразу на экран входа, а не «хаб не отвечает»
if (!TOKEN_RE.test(token)) throw new AuthError("недопустимые символы в токене");
const headers: Record<string, string> = { Authorization: "Bearer " + token, Accept: "application/json" };
if (body !== undefined) headers["Content-Type"] = "application/json; charset=utf-8";
let resp: Response;
try {
resp = await fetch(path, { method, headers, body: body === undefined ? undefined : JSON.stringify(body), cache: "no-store" });
} catch {
throw new HubDownError("хаб не отвечает");
}
if (resp.status === 401) throw new AuthError("токен не подошёл");
let data: unknown = null;
try { data = await resp.json(); } catch { /* тело не JSON */ }
if (resp.status >= 500) throw new HubDownError(`хаб ответил ${resp.status}`);
if (!resp.ok) {
const d = (data ?? {}) as { error?: string; detail?: unknown };
throw new ApiError(resp.status, d.error ?? "http", d.detail === undefined || d.detail === null ? null : String(d.detail));
}
return data as T;
}
export const api = {
get: <T>(path: string, params?: Params) => call<T>("GET", withParams(path, params)),
post: <T>(path: string, body: unknown = {}) => call<T>("POST", path, body),
put: <T>(path: string, body: unknown) => call<T>("PUT", path, body),
};
// проверка токена до сохранения: экран входа
export const checkToken = (token: string) => call<HubSettings>("GET", "/settings", undefined, token);

46
web/src/api/queries.ts Normal file
View File

@ -0,0 +1,46 @@
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api } from "./client.ts";
import type { BoardSnapshot, HubSettings, IssueDetail, IssueRow, Note, NoteFields } from "./types.ts";
export const ISSUE_FILTER_NAMES = ["sprint", "assignee", "work_kind", "fact_status", "status", "label", "epic", "neighbor", "q"] as const;
export type IssueFilters = Partial<Record<(typeof ISSUE_FILTER_NAMES)[number], string>>;
export const ISSUE_PAGE = 50;
export const qk = {
settings: ["settings"] as const,
board: ["board"] as const,
issues: (f: IssueFilters) => ["issues", f] as const,
issue: (key: string) => ["issue", key] as const,
};
export const useSettings = () =>
useQuery({ queryKey: qk.settings, queryFn: () => api.get<HubSettings>("/settings"), staleTime: Infinity });
export const useBoard = () => useQuery({ queryKey: qk.board, queryFn: () => api.get<BoardSnapshot>("/board") });
export const useIssue = (key: string) =>
useQuery({ queryKey: qk.issue(key), queryFn: () => api.get<IssueDetail>(`/issues/${encodeURIComponent(key)}`) });
// список порциями по ISSUE_PAGE: следующая порция есть, пока последняя полная
export const useIssues = (filters: IssueFilters) =>
useInfiniteQuery({
queryKey: qk.issues(filters),
initialPageParam: 0,
queryFn: ({ pageParam }) =>
api.get<{ issues: IssueRow[] }>("/issues", { ...filters, limit: ISSUE_PAGE, offset: pageParam }).then((r) => r.issues),
getNextPageParam: (last, all) => (last.length < ISSUE_PAGE ? undefined : all.length * ISSUE_PAGE),
});
// сохранение заметки: key фиксируется при создании мутации — ответ пишется в карточку той задачи, для которой отправлен
export function useSaveNote(key: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (fields: NoteFields) =>
api.put<Note>(`/issues/${encodeURIComponent(key)}/note`, { ...fields, updated_by: "web" }),
onSuccess: (note) => {
qc.setQueryData<IssueDetail>(qk.issue(key), (d) => (d ? { ...d, note } : d));
void qc.invalidateQueries({ queryKey: ["issues"] });
void qc.invalidateQueries({ queryKey: qk.board });
},
});
}

View File

@ -0,0 +1,13 @@
import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query";
import { clearToken, getToken } from "../auth/token.ts";
import { AuthError } from "./client.ts";
// 401 в любом запросе или действии — экран входа с пояснением «токен не подошёл»
export function makeQueryClient(): QueryClient {
const onError = (e: unknown) => { if (e instanceof AuthError) clearToken(getToken() ? "bad" : "none"); };
return new QueryClient({
queryCache: new QueryCache({ onError }),
mutationCache: new MutationCache({ onError }),
defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false, staleTime: 30_000 } },
});
}

54
web/src/api/types.ts Normal file
View File

@ -0,0 +1,54 @@
// ответы HTTP API хаба (openspec/specs/hub-api); поля — как отдаёт хаб, без переименований
export interface BoardIssue {
key: string; summary: string | null; issue_type: string | null; status: string | null; priority: string | null;
assignee: string | null; sprint: string | null; duedate: string | null; planned_start: string | null;
planned_end: string | null; epic_key: string | null; labels: string[]; neighbor: boolean;
fact_status: string | null; work_kind: string | null;
}
export interface BoardLink { source: string; target: string; link_type: string }
export interface SyncRun { started_at: string; finished_at: string; issues_count: number }
export interface BoardSnapshot { generated_at: string; last_sync: SyncRun | null; issues: BoardIssue[]; links: BoardLink[] }
// словари настроек: kind_labels — вид работы → метка Jira, link_types — логический тип → имя типа связи Jira,
// status_labels — статус по факту → метка Jira
export interface HubSettings {
kind_labels?: Record<string, string>;
link_types?: Record<string, string>;
status_labels?: Record<string, string>;
[key: string]: unknown;
}
export interface IssueRow {
key: string; id: string | null; issue_type: string | null; summary: string | null; description: string | null;
status: string | null; priority: string | null; assignee: string | null; reporter: string | null;
duedate: string | null; planned_start: string | null; planned_end: string | null; epic_key: string | null;
labels: string[]; sprint: string | null; created: string | null; jira_updated: string | null; fetched_at: string;
neighbor: boolean; fact_status?: string | null; work_kind?: string | null;
}
export const NOTE_FIELDS = ["fact_status", "work_kind", "done_text", "rest_text", "owner_note"] as const;
export type NoteField = (typeof NOTE_FIELDS)[number];
export type NoteFields = Partial<Record<NoteField, string | null>>;
export type Note = Record<NoteField, string | null> & { key: string; updated_at: string; updated_by: string };
export interface IssueCommand {
id: number; batch_id: number; type: string; status: string; finished_at: string | null; error: string | null;
}
export interface IssueDetail extends IssueRow {
links_out: { target_key: string; link_type: string }[];
links_in: { source_key: string; link_type: string }[];
note: Note | null;
commands: IssueCommand[];
}
export type BatchStatus = "queued" | "running" | "done" | "cancelled";
export interface BatchCommand {
id: number; seq: number; type: string; status: string; error: string | null; finished_at: string | null;
}
export interface Batch {
id: number; title: string; author: string; created_at: string; cancelled_at: string | null; status: BatchStatus;
counts: Record<string, number>; remaining_seconds: number; commands?: BatchCommand[];
}
export interface AgentView { online: boolean; last_seen_at: string | null; warning: string | null }
export interface CreatedBatch { id: number; estimate_seconds: number; count: number }

View File

@ -0,0 +1,9 @@
import type { ReactNode } from "react";
import { LoginScreen } from "./LoginScreen.tsx";
import { useAuth } from "./useAuth.ts";
// пока нет токена — только экран входа: страниц и их фоновых запросов нет (M-6)
export function AuthGate({ children }: { children: ReactNode }) {
const { token } = useAuth();
return token ? <>{children}</> : <LoginScreen />;
}

View File

@ -0,0 +1,65 @@
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it } from "vitest";
import { json, mockFetch } from "../test/fetchMock.ts";
import { renderWithProviders } from "../test/render.tsx";
import { AuthGate } from "./AuthGate.tsx";
import { clearToken, getAuth } from "./token.ts";
const app = <AuthGate><p>страница приложения</p></AuthGate>;
describe("вход", () => {
beforeEach(() => clearToken());
it("без токена — экран входа, страница не видна", () => {
renderWithProviders(app);
expect(screen.getByRole("heading", { name: "Вход в хаб" })).toBeInTheDocument();
expect(screen.queryByText("страница приложения")).not.toBeInTheDocument();
expect(screen.getByText(/uv run jira-hub tokens/)).toBeInTheDocument();
});
it("верный токен сохраняется и открывает приложение", async () => {
const fetch = mockFetch(() => json(200, {}));
renderWithProviders(app);
await userEvent.type(screen.getByLabelText("Клиентский токен хаба"), "good-token");
await userEvent.click(screen.getByRole("button", { name: "Войти" }));
expect(await screen.findByText("страница приложения")).toBeInTheDocument();
expect(localStorage.getItem("jira-hub-token")).toBe("good-token");
expect(fetch.mock.calls[0][0]).toBe("/settings");
});
it("неверный токен не сохраняется, поле не стирается", async () => {
mockFetch(() => json(401, { error: "unauthorized" }));
renderWithProviders(app);
const input = screen.getByLabelText("Клиентский токен хаба");
await userEvent.type(input, "wrong");
await userEvent.click(screen.getByRole("button", { name: "Войти" }));
expect(await screen.findByText(/Токен не подошёл/)).toBeInTheDocument();
expect(input).toHaveValue("wrong");
expect(localStorage.getItem("jira-hub-token")).toBeNull();
});
it("кириллица или пробел — «недопустимые символы», запроса нет", async () => {
const fetch = mockFetch(() => json(200, {}));
renderWithProviders(app);
await userEvent.type(screen.getByLabelText("Клиентский токен хаба"), "ток ен");
await userEvent.click(screen.getByRole("button", { name: "Войти" }));
expect(screen.getByText(/недопустимые символы/)).toBeInTheDocument();
expect(fetch).not.toHaveBeenCalled();
});
it("хаб не отвечает — своё сообщение, не «токен не подошёл»", async () => {
mockFetch(() => { throw new TypeError("Failed to fetch"); });
renderWithProviders(app);
await userEvent.type(screen.getByLabelText("Клиентский токен хаба"), "tok");
await userEvent.click(screen.getByRole("button", { name: "Войти" }));
expect(await screen.findByText(/Хаб не отвечает/)).toBeInTheDocument();
});
it("после 401 в приложении экран входа поясняет причину", async () => {
clearToken("bad");
renderWithProviders(app);
await waitFor(() => expect(getAuth().reason).toBe("bad"));
expect(screen.getByText(/Токен не подошёл/)).toBeInTheDocument();
});
});

View File

@ -0,0 +1,48 @@
import { useQueryClient } from "@tanstack/react-query";
import { useState, type FormEvent } from "react";
import { AuthError, checkToken } from "../api/client.ts";
import { setToken, TOKEN_RE } from "./token.ts";
import { useAuth } from "./useAuth.ts";
const BAD_TOKEN = "Токен не подошёл. Проверьте его по uv run jira-hub tokens: токен могли пересоздать.";
const BAD_CHARS = "В токене недопустимые символы: скопируйте строку после «клиентский токен:» целиком, без пробелов и кириллицы.";
const HUB_DOWN = "Хаб не отвечает: проверьте, что jira-hub запущен, и попробуйте снова.";
export function LoginScreen() {
const { reason } = useAuth();
const qc = useQueryClient();
const [value, setValue] = useState("");
const [error, setError] = useState(reason === "bad" ? BAD_TOKEN : "");
const [busy, setBusy] = useState(false);
async function submit(ev: FormEvent) {
ev.preventDefault();
const t = value.trim();
if (!t) return;
if (!TOKEN_RE.test(t)) { setError(BAD_CHARS); return; }
setBusy(true);
try {
await checkToken(t);
qc.clear();
setToken(t);
} catch (e) {
setError(e instanceof AuthError ? BAD_TOKEN : HUB_DOWN);
} finally {
setBusy(false);
}
}
return (
<div className="login">
<h2>Вход в хаб</h2>
<p>Приложению нужен клиентский токен хаба. Его показывает команда <code>uv run jira-hub tokens</code> в каталоге
jira-hub на этом Mac. Токен сохранится в этом браузере.</p>
{error && <p className="err" role="alert">{error}</p>}
<form onSubmit={submit}>
<input type="password" autoComplete="off" spellCheck={false} aria-label="Клиентский токен хаба"
placeholder="клиентский токен" value={value} onChange={(e) => setValue(e.target.value)} />
<button className="btn" type="submit" disabled={busy}>Войти</button>
</form>
</div>
);
}

View File

@ -0,0 +1,40 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
describe("token", () => {
beforeEach(() => { vi.resetModules(); localStorage.clear(); });
afterEach(() => { vi.restoreAllMocks(); });
it("читает токен, сохранённый прежней доской", async () => {
localStorage.setItem("jira-hub-token", "abc");
const t = await import("./token.ts");
expect(t.getToken()).toBe("abc");
});
it("setToken и clearToken пишут в localStorage и оповещают подписчиков", async () => {
const t = await import("./token.ts");
const fn = vi.fn();
t.subscribe(fn);
t.setToken("xyz");
expect(localStorage.getItem("jira-hub-token")).toBe("xyz");
t.clearToken("bad");
expect(t.getAuth()).toEqual({ token: "", reason: "bad" });
expect(localStorage.getItem("jira-hub-token")).toBeNull();
expect(fn).toHaveBeenCalledTimes(2);
});
it("закрытое хранилище: токен живёт в памяти, исключений нет", async () => {
vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => { throw new DOMException("closed", "SecurityError"); });
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { throw new DOMException("closed", "SecurityError"); });
const t = await import("./token.ts");
expect(t.getToken()).toBe("");
t.setToken("mem");
expect(t.getToken()).toBe("mem");
});
it("TOKEN_RE пропускает только печатный ASCII без пробелов", async () => {
const { TOKEN_RE } = await import("./token.ts");
expect(TOKEN_RE.test("Ab-_9~")).toBe(true);
expect(TOKEN_RE.test("ab c")).toBe(false);
expect(TOKEN_RE.test("токен")).toBe(false);
});
});

33
web/src/auth/token.ts Normal file
View File

@ -0,0 +1,33 @@
// клиентский токен хаба: localStorage["jira-hub-token"] (тот же ключ, что у прежней доски) и копия в памяти на случай
// закрытого хранилища. reason — почему показан экран входа: "bad" — токен не подошёл (ответ 401)
export const TOKEN_KEY = "jira-hub-token";
export const TOKEN_RE = /^[\x21-\x7e]+$/; // печатный ASCII без пробелов: иначе fetch бросит TypeError на заголовке
export type LoginReason = "none" | "bad";
export interface AuthSnapshot { token: string; reason: LoginReason }
const readStored = (): string => {
try { return localStorage.getItem(TOKEN_KEY) ?? ""; } catch { return ""; }
};
let state: AuthSnapshot = { token: readStored(), reason: "none" };
const listeners = new Set<() => void>();
const emit = (next: AuthSnapshot) => { state = next; listeners.forEach((l) => l()); };
export const getAuth = (): AuthSnapshot => state;
export const getToken = (): string => state.token;
export function setToken(token: string): void {
try { localStorage.setItem(TOKEN_KEY, token); } catch { /* закрытое хранилище: токен только в памяти */ }
emit({ token, reason: "none" });
}
export function clearToken(reason: LoginReason = "none"): void {
try { localStorage.removeItem(TOKEN_KEY); } catch { /* закрытое хранилище */ }
emit({ token: "", reason });
}
export function subscribe(fn: () => void): () => void {
listeners.add(fn);
return () => { listeners.delete(fn); };
}

4
web/src/auth/useAuth.ts Normal file
View File

@ -0,0 +1,4 @@
import { useSyncExternalStore } from "react";
import { getAuth, subscribe, type AuthSnapshot } from "./token.ts";
export const useAuth = (): AuthSnapshot => useSyncExternalStore(subscribe, getAuth);

View File

@ -0,0 +1,135 @@
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { beforeEach, describe, expect, it } from "vitest";
import { setToken } from "../auth/token.ts";
import { json, mockFetch } from "../test/fetchMock.ts";
import { renderWithProviders } from "../test/render.tsx";
import settings from "./__fixtures__/settings.json";
import { BoardPage } from "./BoardPage.tsx";
const fx = (name: string) => readFileSync(join(import.meta.dirname, "__fixtures__", name), "utf8");
const SNAP = JSON.parse(fx("hub-basic.board.json"));
function hub(over: Record<string, () => Response> = {}) {
return mockFetch((method, url) => {
const k = `${method} ${url.pathname}`;
if (over[k]) return over[k]();
if (k === "GET /settings") return json(200, settings);
if (k === "GET /board") return json(200, SNAP);
if (k === "GET /batches") return json(200, { batches: [] });
if (k === "GET /agent") return json(200, { online: true });
if (k === "POST /refresh") return json(201, { id: 7, estimate_seconds: 5, count: 1 });
if (k === "GET /batches/7") return json(200, { id: 7, author: "viewer", status: "running", remaining_seconds: 3, counts: {} });
return json(404, { error: "not_found" });
});
}
const page = () => renderWithProviders(<BoardPage />, { route: "/board", path: "/board" });
describe("страница доски", () => {
beforeEach(() => setToken("tok"));
it("строит доску из хаба: источник, статистика, выбор самого наполненного спринта", async () => {
hub();
page();
expect(await screen.findByText(/^Хаб · данные на/)).toBeInTheDocument();
expect(screen.getByText(/задач из 9/)).toBeInTheDocument();
expect(screen.getByRole("button", { name: /AWPCA_Спринт 2/ })).toBeInTheDocument();
});
it("пустые словари в настройках — сообщение, а не пустая доска", async () => {
hub({ "GET /settings": () => json(200, { kind_labels: {}, link_types: {} }) });
page();
expect(await screen.findByText(/В настройках хаба нет kind_labels и link_types/)).toBeInTheDocument();
expect(document.querySelector(".board")).toBeNull();
});
it("переключатели и легенда сохраняются под прежними ключами", async () => {
hub();
page();
await screen.findByText(/задач из 9/);
const nb = screen.getByRole("button", { name: "+ соседи по связям" });
await userEvent.click(nb);
expect(nb).toHaveAttribute("aria-pressed", "true");
expect(localStorage.getItem("graph-neighbors")).toBe("1");
await userEvent.click(within(document.querySelector(".legend") as HTMLElement).getByRole("button", { name: /разработка/ }));
expect(localStorage.getItem("graph-hidekind")).toBe('["dev"]');
});
it("«Проблемы» открывают ящик, счётчик — сроки, исполнители, статусы", async () => {
hub();
page();
await screen.findByText(/задач из 9/);
await userEvent.click(screen.getByRole("button", { name: /Проблемы/ }));
expect(screen.getByText("Проблемы на доске")).toBeInTheDocument();
expect(localStorage.getItem("graph-prob")).toBe("1");
});
it("экспорт JSON показывает выгрузку arm-ka-links/1 с раскладкой", async () => {
hub();
page();
await screen.findByText(/задач из 9/);
await userEvent.click(screen.getByTitle("Ещё"));
await userEvent.click(screen.getByRole("button", { name: "Экспорт JSON" }));
const text = (screen.getByRole("textbox") as HTMLTextAreaElement).value;
expect(JSON.parse(text)).toMatchObject({ format: "arm-ka-links/1", source: "Хаб" });
expect(JSON.parse(text).nodes.find((n: { key: string }) => n.key === "AURORA-9001").layout).not.toBeNull();
});
it("сброс раскладки — с подтверждением в меню", async () => {
hub();
localStorage.setItem("graph-pos:board", '{"AURORA-9001":{"x":1,"ry":40}}');
page();
await screen.findByText(/задач из 9/);
await userEvent.click(screen.getByTitle("Ещё"));
await userEvent.click(screen.getByRole("button", { name: "Сбросить раскладку" }));
await userEvent.click(screen.getByRole("button", { name: "Да, сбросить" }));
expect(localStorage.getItem("graph-pos:board")).toBeNull();
});
it("загрузка CSV — источник «из файла»", async () => {
hub();
page();
await screen.findByText(/задач из 9/);
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
fireEvent.change(input, { target: { files: [new File([fx("jira-export.csv")], "jira-export.csv", { type: "text/csv" })] } });
expect(await screen.findByText("jira-export.csv (из файла)")).toBeInTheDocument();
});
it("тема переключается по кругу и применяется к документу", async () => {
hub();
page();
await screen.findByText(/задач из 9/);
await userEvent.click(screen.getByTitle("Ещё"));
await userEvent.click(screen.getByRole("button", { name: "Тема: системная" }));
expect(document.documentElement.getAttribute("data-theme")).toBe("light");
expect(localStorage.getItem("graph-theme")).toBe("light");
});
it("«Обновить из Jira» ставит пакет и недоступна, пока он идёт", async () => {
const fetch = hub();
page();
await screen.findByText(/задач из 9/);
const btn = screen.getByRole("button", { name: "Обновить из Jira" });
await userEvent.click(btn);
await waitFor(() => expect(btn).toBeDisabled());
expect(fetch.mock.calls.filter(([u, i]) => u === "/refresh" && i?.method === "POST")).toHaveLength(1);
expect(await screen.findByText(/пакет #7 выполняется/)).toBeInTheDocument();
});
it("заголовок страницы — h1, пока идёт пакет в шапке label и строка состояния", async () => {
hub();
page();
await screen.findByText(/задач из 9/);
expect(screen.getByRole("heading", { level: 1 })).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Обновить из Jira" }));
expect(await screen.findByRole("status")).toHaveTextContent(/пакет #7/);
expect(screen.getByText(/^Хаб · данные на/)).toBeInTheDocument();
});
it("пустой link_types при заполненном kind_labels — называет только недостающий словарь", async () => {
hub({ "GET /settings": () => json(200, { kind_labels: { dev: "разработка" }, link_types: {} }) });
page();
expect(await screen.findByText(/В настройках хаба нет link_types —/)).toBeInTheDocument();
});
});

View File

@ -0,0 +1,90 @@
import { useLayoutEffect, useMemo, useRef, useState } from "react";
import { useSettings } from "../api/queries.ts";
import { computeVisible, emptyReason } from "./analysis/visible.ts";
import { BoardCanvas } from "./components/BoardCanvas.tsx";
import { ExportBox } from "./components/ExportBox.tsx";
import { FilterBar } from "./components/FilterBar.tsx";
import { Legend } from "./components/Legend.tsx";
import { LinkTable } from "./components/LinkTable.tsx";
import { problemCount, ProblemsDrawer } from "./components/ProblemsDrawer.tsx";
import { Notice, Toolbar } from "./components/Toolbar.tsx";
import { dictsFromSettings } from "./dicts.ts";
import { exportFileName, exportJson } from "./exportJson.ts";
import { todayIso } from "./model/util.ts";
import { useBoardPrefs } from "./useBoardPrefs.ts";
import { useBoardSource } from "./useBoardSource.ts";
import "./board.css";
export function BoardPage() {
const settings = useSettings();
const d = useMemo(() => (settings.data ? dictsFromSettings(settings.data) : null), [settings.data]);
const src = useBoardSource(d && !d.missing.length ? d.dicts : null);
const prefs = useBoardPrefs(src.model);
const today = todayIso();
const vis = useMemo(() => computeVisible(src.model, prefs.filters, prefs.store.laneOrder, today),
[src.model, prefs.filters, prefs.store.laneOrder, today]);
const [exportText, setExportText] = useState<string | null>(null);
const saveRef = useRef<(() => void) | null>(null);
const headRef = useRef<HTMLDivElement>(null);
// высота закреплённой шапки — ящик «Проблемы» и полоса дат липнут под ней (строки 1016–1019)
useLayoutEffect(() => {
const el = headRef.current;
if (!el) return;
const set = () => document.documentElement.style.setProperty("--head-h", (getComputedStyle(el).position === "sticky" ? el.offsetHeight : 0) + "px");
set();
if (typeof ResizeObserver === "undefined") return;
const ro = new ResizeObserver(set);
ro.observe(el);
return () => ro.disconnect();
});
if (settings.isPending) return <p className="hint" data-testid="page-board">Загрузка настроек хаба…</p>;
if (settings.error) return <p className="warn" data-testid="page-board">Настройки хаба не загрузились: {settings.error.message}</p>;
if (d && d.missing.length) {
return (
<div className="notice" role="alert" data-testid="page-board">
В настройках хаба нет {d.missing.join(" и ")} — доска не может перевести виды работ и типы связей Jira. Задайте
словари в config.example.json и выполните <code>uv run jira-hub init --config …</code> или запишите их через PUT /settings.
</div>
);
}
const onExport = () => {
saveRef.current?.();
const now = new Date().toISOString();
const text = JSON.stringify(exportJson(src.model, vis, prefs.filters, now), null, 2);
setExportText(text);
try { // в обычном браузере файл скачается; если нет — остаются текст и «Копировать»
const a = document.createElement("a");
a.href = URL.createObjectURL(new Blob([text], { type: "application/json" }));
a.download = exportFileName(src.model, now);
document.body.appendChild(a); a.click();
setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 1000);
} catch { /* остаётся текст */ }
};
const onFile = (name: string, text: string) => { const r = src.loadFile(name, text); if (r) prefs.applyJson(r); };
return (
<div data-testid="page-board">
<div className="sticky" ref={headRef}>
<Toolbar label={src.label} title={src.title} status={src.status} refreshing={src.refreshing} byDate={prefs.filters.byDate}
snap={prefs.snap} model={src.model} vis={vis} problemCount={problemCount(vis.problems)} problemsOpen={prefs.problems}
tableOpen={prefs.table} onReload={src.reload} onRefresh={() => void src.refreshFromJira()} onFile={onFile}
onProblems={() => prefs.toggle("problems")} onExport={onExport} onTable={() => prefs.toggle("table")}
onSnap={() => prefs.toggle("snap")} onReset={prefs.resetLayout} />
<FilterBar model={src.model} prefs={prefs} />
<Notice model={src.model} />
<Legend prefs={prefs} />
</div>
{exportText !== null && <ExportBox text={exportText} onClose={() => setExportText(null)} />}
<BoardCanvas vis={vis} byDate={prefs.filters.byDate} snap={prefs.snap} today={today} store={prefs.store}
sprintSel={prefs.filters.sprints} emptyText={vis.nodes.length ? "" : emptyReason(src.model, vis, prefs.filters)}
onToggleLane={prefs.toggleLane} onMoveLane={(name, to) => prefs.moveLane(vis.lanes.map((l) => l.name), name, to)}
onColWidth={prefs.setColWidth} saveRef={saveRef}>
{prefs.problems && <ProblemsDrawer problems={vis.problems} onClose={() => prefs.toggle("problems")} />}
</BoardCanvas>
{prefs.table && <LinkTable links={vis.links} />}
</div>
);
}

View File

@ -0,0 +1,12 @@
{"edge": [
{"a": {"x": 24, "y": 40, "w": 240, "h": 120}, "b": {"x": 320, "y": 40, "w": 240, "h": 120}},
{"a": {"x": 24, "y": 40, "w": 240, "h": 120}, "b": {"x": 320, "y": 200, "w": 240, "h": 96}},
{"a": {"x": 620, "y": 40, "w": 240, "h": 120}, "b": {"x": 24, "y": 300, "w": 240, "h": 120}},
{"a": {"x": 24, "y": 40, "w": 240, "h": 120}, "b": {"x": 100, "y": 100, "w": 240, "h": 120}},
{"a": {"x": 24, "y": 40, "w": 240, "h": 120}, "b": {"x": 30, "y": 400, "w": 240, "h": 120}},
{"a": {"x": 24, "y": 400, "w": 240, "h": 120}, "b": {"x": 24, "y": 40, "w": 240, "h": 120}}],
"ortho": [[{"x": 0, "y": 0}, {"x": 10, "y": 0}], [{"x": 0, "y": 0}, {"x": 1, "y": 0}, {"x": 1, "y": 30}, {"x": 40, "y": 30}]],
"align": [
{"pos": {"A": {"x": 24, "y": 40, "w": 240, "h": 120}, "B": {"x": 320, "y": 200, "w": 240, "h": 96}}, "key": "B", "r": {"x": 28, "y": 203, "w": 240, "h": 96}, "resizing": false},
{"pos": {"A": {"x": 24, "y": 40, "w": 240, "h": 120}, "B": {"x": 320, "y": 200, "w": 240, "h": 96}}, "key": "B", "r": {"x": 320, "y": 200, "w": 262, "h": 98}, "resizing": true},
{"pos": {"A": {"x": 24, "y": 40, "w": 240, "h": 120}}, "key": "B", "r": {"x": 500, "y": 500, "w": 240, "h": 96}, "resizing": false}]}

View File

@ -0,0 +1,56 @@
{
"align": [
{
"guides": [
{
"x1": 24,
"x2": 24,
"y1": 0,
"y2": 0
}
],
"out": {
"h": 96,
"snappedX": true,
"snappedY": false,
"w": 240,
"x": 24,
"y": 203
}
},
{
"guides": [],
"out": {
"h": 98,
"snappedX": false,
"snappedY": false,
"w": 262,
"x": 320,
"y": 200
}
},
{
"guides": [],
"out": {
"h": 96,
"snappedX": false,
"snappedY": false,
"w": 240,
"x": 500,
"y": 500
}
}
],
"edge": [
"M264 100 L320 100",
"M264 100 L282 100 Q292 100 292 110 L292 238 Q292 248 302 248 L320 248",
"M620 100 L452 100 Q442 100 442 110 L442 350 Q442 360 432 360 L264 360",
"M264 100 L358 100 Q368 100 368 110 L368 150 Q368 160 358 160 L340 160",
"M144 160 L144 277 Q144 280 147 280 L147 280 Q150 280 150 283 L150 400",
"M144 400 L144 160"
],
"ortho": [
"M0 0 L10 0",
"M0 0 L1 0 L1 20 Q1 30 11 30 L40 30"
]
}

View File

@ -0,0 +1,29 @@
{
"generated_at": "2026-10-14T10:00:00+00:00",
"last_sync": {"started_at": "2026-10-14T09:00:00+00:00", "finished_at": "2026-10-14T09:01:00+00:00", "issues_count": 10},
"issues": [
{"key": "AURORA-9100", "summary": "Тестовый эпик", "issue_type": "Фича", "status": "В работе", "priority": "Средний", "assignee": null, "sprint": null, "duedate": null, "planned_start": null, "planned_end": null, "epic_key": null, "labels": ["AWPCA"], "neighbor": false, "fact_status": null, "work_kind": null},
{"key": "AURORA-9001", "summary": "Схема свода", "issue_type": "Задача", "status": "В работе", "priority": "Высокий", "assignee": "Иванов Иван Иванович", "sprint": "AWPCA_Спринт 2", "duedate": "2026-10-30", "planned_start": null, "planned_end": "2026-10-20", "epic_key": "AURORA-9100", "labels": ["AWPCA", "kind-dev"], "neighbor": false, "fact_status": "В работе", "work_kind": "разработка"},
{"key": "AURORA-9002", "summary": "API свода", "issue_type": "Задача", "status": "Ожидание разработки", "priority": "Средний", "assignee": "Петров Пётр Петрович", "sprint": "AWPCA_Спринт 2", "duedate": "2026-10-20", "planned_start": null, "planned_end": null, "epic_key": "AURORA-9100", "labels": ["kind-dev"], "neighbor": false, "fact_status": null, "work_kind": null},
{"key": "AURORA-9003", "summary": "Макет экрана", "issue_type": "Задача", "status": "Backlog", "priority": "Критичный", "assignee": null, "sprint": "AWPCA_Спринт 2", "duedate": null, "planned_start": null, "planned_end": "2026-10-16", "epic_key": "AURORA-9100", "labels": ["kind-ui"], "neighbor": false, "fact_status": null, "work_kind": null},
{"key": "AURORA-9004", "summary": "Решение по порогам", "issue_type": "Задача", "status": "К выполнению", "priority": "Наивысший", "assignee": "Сидорова Анна", "sprint": "AWPCA_Спринт 3", "duedate": null, "planned_start": null, "planned_end": "2026-10-25", "epic_key": "AURORA-1359", "labels": ["kind-decision"], "neighbor": false, "fact_status": null, "work_kind": null},
{"key": "AURORA-9005", "summary": "Тест-кейсы", "issue_type": "Задача", "status": "Ожидание", "priority": "Низкий", "assignee": "Петров Пётр Петрович", "sprint": "AWPCA_Спринт 3", "duedate": null, "planned_start": null, "planned_end": null, "epic_key": "AURORA-1359", "labels": ["kind-test"], "neighbor": false, "fact_status": null, "work_kind": null},
{"key": "AURORA-9006", "summary": "Задача без эпика", "issue_type": "Задача", "status": "Готово", "priority": "Средний", "assignee": "Иванов Иван Иванович", "sprint": null, "duedate": "2026-10-10", "planned_start": null, "planned_end": null, "epic_key": null, "labels": [], "neighbor": false, "fact_status": null, "work_kind": null},
{"key": "AURORA-9007", "summary": "Сосед по связям", "issue_type": "Задача", "status": "В работе", "priority": "Средний", "assignee": "Kozlov-AA", "sprint": "AWPCA_Спринт 1", "duedate": null, "planned_start": null, "planned_end": "2026-10-12", "epic_key": "AURORA-9100", "labels": ["kind-analytics"], "neighbor": true, "fact_status": null, "work_kind": null},
{"key": "AURORA-9008", "summary": "Актуализация ТЗ", "issue_type": "Задача", "status": "Ожидание разработки", "priority": "Самый низкий", "assignee": "Иванов Иван Иванович", "sprint": "AWPCA_Спринт 2", "duedate": null, "planned_start": null, "planned_end": "2026-10-18", "epic_key": "AURORA-9999", "labels": ["kind-docs"], "neighbor": false, "fact_status": null, "work_kind": null},
{"key": "AURORA-9009", "summary": "Созвон с заказчиком", "issue_type": "Задача", "status": "", "priority": "Средний", "assignee": "Иванов Иван Иванович", "sprint": "AWPCA_Спринт 2", "duedate": null, "planned_start": null, "planned_end": "2026-10-20", "epic_key": "AURORA-9100", "labels": ["kind-org"], "neighbor": false, "fact_status": null, "work_kind": null}
],
"links": [
{"source": "AURORA-9001", "target": "AURORA-9002", "link_type": "ASUPR_Блокер_Начала"},
{"source": "AURORA-9003", "target": "AURORA-9001", "link_type": "ASUPR_Блокер_Начала"},
{"source": "AURORA-9002", "target": "AURORA-9005", "link_type": "ASUPR_Блокер_Завершения"},
{"source": "AURORA-9004", "target": "AURORA-9002", "link_type": "ASUPR_Блокер_Начала"},
{"source": "AURORA-9001", "target": "AURORA-9008", "link_type": "ASUPR_Связанные"},
{"source": "AURORA-9008", "target": "AURORA-9001", "link_type": "ASUPR_Связанные"},
{"source": "AURORA-9006", "target": "AURORA-9500", "link_type": "ASUPR_Блокер_Начала"},
{"source": "AURORA-9001", "target": "AURORA-9100", "link_type": "ASUPR_Блокер_Начала"},
{"source": "AURORA-9009", "target": "AURORA-9002", "link_type": "Duplicate"},
{"source": "AURORA-9001", "target": "lower-1", "link_type": "ASUPR_Связанные"},
{"source": "AURORA-9007", "target": "AURORA-9009", "link_type": "ASUPR_Блокер_Начала"}
]
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1 @@
{"generated_at": "2026-10-14T10:00:00+00:00", "last_sync": null, "issues": [], "links": []}

View File

@ -0,0 +1,93 @@
{
"model": {
"fileName": "Хаб",
"hasSprint": true,
"links": [],
"missing": 0,
"nodes": [],
"problems": [
"в хабе ещё не было полного обновления из Jira — нажмите «Обновить из Jira»"
],
"sprints": {},
"stubLane": "Нет в зеркале"
},
"scenario": {
"emptyReason": "В зеркале хаба нет задач.",
"layout": {
"baseW": 1208,
"board": {
"height": "auto",
"width": "100%"
},
"clamp": {},
"colBounds": {},
"dateCols": [],
"dateLabels": [
{
"className": "dlabel none",
"innerHTML": "без срока<span class=\"n\">1</span>",
"left": "20px",
"title": "плановая дата не задана"
},
{
"className": "dlabel past",
"innerHTML": "10.10<span class=\"n\">1</span>",
"left": "316px",
"title": "срок прошёл"
},
{
"className": "dlabel past",
"innerHTML": "12.10<span class=\"n\">1</span>",
"left": "612px",
"title": "срок прошёл"
},
{
"className": "dlabel",
"innerHTML": "16.10<span class=\"n\">1</span>",
"left": "908px",
"title": "плановая дата окончания"
},
{
"className": "dlabel",
"innerHTML": "18.10<span class=\"n\">1</span>",
"left": "1204px",
"title": "плановая дата окончания"
},
{
"className": "dlabel",
"innerHTML": "20.10<span class=\"n\">3</span>",
"left": "1500px",
"title": "плановая дата окончания"
},
{
"className": "dlabel",
"innerHTML": "25.10<span class=\"n\">1</span>",
"left": "1900px",
"title": "плановая дата окончания"
}
],
"edges": [],
"laneOf": {},
"lanes": [],
"pos": {},
"rel": {}
},
"visible": {
"lanes": [],
"links": [],
"nodes": [],
"problems": {
"dates": [],
"hand": [],
"late": [],
"unassigned": []
},
"removed": {
"core": 0,
"ctx": 0,
"edge": 0,
"loose": 0
}
}
}
}

View File

@ -0,0 +1,2 @@
Номер,Название
1,x
1 Номер Название
2 1 x

View File

@ -0,0 +1,3 @@
{
"error": "не нашёл колонки «Ключ проблемы» и «Тема»; в файле: Номер, Название…"
}

View File

@ -0,0 +1,7 @@
Ключ проблемы,Тема,Статус,Приоритет,Тип задачи,Метки,Метки,Связи Фичи,Плановая дата окончания,Исполнитель,Спринт,Спринт,Внешняя ссылка на проблему (ASUPR_Блокер_Начала),Внутренняя ссылка на проблему (ASUPR_Блокер_Начала),Внешняя ссылка на проблему (ASUPR_Связанные)
AURORA-9100,Тестовый эпик,В работе,Средний,Фича,AWPCA,,,,,,,,,
AURORA-9001,"Схема свода, черновик",В работе,Высокий,Задача,AWPCA,kind-dev,AURORA-9100,20.10.2026,Иванов Иван Иванович,AWPCA_Спринт 1,AWPCA_Спринт 2,AURORA-9002,AURORA-9003,AURORA-9008
AURORA-9002,"API ""свода""",Ожидание разработки,Средний,Задача,kind-dev,,AURORA-9100,20/окт/26,Петров Пётр Петрович,AWPCA_Спринт 2,,,,
AURORA-9003,Макет экрана,Backlog,Критичный,Задача,kind-ui,,AURORA-1359,2026-10-16,,AWPCA_Спринт 2,,AURORA-9001,,
AURORA-9006,Задача без эпика,Готово,Средний,Задача,,,,10/Oct/26 12:00 PM,Иванов Иван Иванович,,,AURORA-9500,,
not-a-key,мусор,,,,,,,,,,,,,
1 Ключ проблемы Тема Статус Приоритет Тип задачи Метки Метки Связи Фичи Плановая дата окончания Исполнитель Спринт Спринт Внешняя ссылка на проблему (ASUPR_Блокер_Начала) Внутренняя ссылка на проблему (ASUPR_Блокер_Начала) Внешняя ссылка на проблему (ASUPR_Связанные)
2 AURORA-9100 Тестовый эпик В работе Средний Фича AWPCA
3 AURORA-9001 Схема свода, черновик В работе Высокий Задача AWPCA kind-dev AURORA-9100 20.10.2026 Иванов Иван Иванович AWPCA_Спринт 1 AWPCA_Спринт 2 AURORA-9002 AURORA-9003 AURORA-9008
4 AURORA-9002 API "свода" Ожидание разработки Средний Задача kind-dev AURORA-9100 20/окт/26 Петров Пётр Петрович AWPCA_Спринт 2
5 AURORA-9003 Макет экрана Backlog Критичный Задача kind-ui AURORA-1359 2026-10-16 AWPCA_Спринт 2 AURORA-9001
6 AURORA-9006 Задача без эпика Готово Средний Задача 10/Oct/26 12:00 PM Иванов Иван Иванович AURORA-9500
7 not-a-key мусор

View File

@ -0,0 +1,228 @@
{
"model": {
"fileName": "jira-export.csv",
"hasSprint": true,
"links": [
{
"dst": "AURORA-9002",
"src": "AURORA-9001",
"type": "блокирует начало"
},
{
"dst": "AURORA-9001",
"src": "AURORA-9003",
"type": "блокирует начало"
},
{
"dst": "AURORA-9008",
"src": "AURORA-9001",
"type": "связанные"
},
{
"dst": "AURORA-9500",
"src": "AURORA-9006",
"type": "блокирует начало"
}
],
"missing": 2,
"nodes": [
{
"due": "2026-10-20",
"epic": "Тестовый эпик",
"key": "AURORA-9001",
"kind": "разработка",
"prio": "Высокий",
"sprint": "AWPCA_Спринт 2",
"status": "В работе",
"stub": false,
"title": "Схема свода, черновик",
"who": "Иванов Иван Иванович"
},
{
"due": "2026-10-20",
"epic": "Тестовый эпик",
"key": "AURORA-9002",
"kind": "разработка",
"prio": "Средний",
"sprint": "AWPCA_Спринт 2",
"status": "Ожидание разработки",
"stub": false,
"title": "API \"свода\"",
"who": "Петров Пётр Петрович"
},
{
"due": "2026-10-16",
"epic": "Гудвилл",
"key": "AURORA-9003",
"kind": "UI",
"prio": "Критичный",
"sprint": "AWPCA_Спринт 2",
"status": "Backlog",
"stub": false,
"title": "Макет экрана",
"who": ""
},
{
"due": "2026-10-10",
"epic": "Без эпика",
"key": "AURORA-9006",
"kind": "Задача",
"prio": "Средний",
"sprint": "",
"status": "Готово",
"stub": false,
"title": "Задача без эпика",
"who": "Иванов Иван Иванович"
},
{
"due": "",
"epic": "Нет в файле",
"key": "AURORA-9008",
"kind": "",
"prio": "",
"sprint": "",
"status": "",
"stub": true,
"title": "нет в файле",
"who": ""
},
{
"due": "",
"epic": "Нет в файле",
"key": "AURORA-9500",
"kind": "",
"prio": "",
"sprint": "",
"status": "",
"stub": true,
"title": "нет в файле",
"who": ""
}
],
"problems": [],
"sprints": {
"AWPCA_Спринт 2": 3
}
},
"rows": [
[
"Ключ проблемы",
"Тема",
"Статус",
"Приоритет",
"Тип задачи",
"Метки",
"Метки",
"Связи Фичи",
"Плановая дата окончания",
"Исполнитель",
"Спринт",
"Спринт",
"Внешняя ссылка на проблему (ASUPR_Блокер_Начала)",
"Внутренняя ссылка на проблему (ASUPR_Блокер_Начала)",
"Внешняя ссылка на проблему (ASUPR_Связанные)"
],
[
"AURORA-9100",
"Тестовый эпик",
"В работе",
"Средний",
"Фича",
"AWPCA",
"",
"",
"",
"",
"",
"",
"",
"",
""
],
[
"AURORA-9001",
"Схема свода, черновик",
"В работе",
"Высокий",
"Задача",
"AWPCA",
"kind-dev",
"AURORA-9100",
"20.10.2026",
"Иванов Иван Иванович",
"AWPCA_Спринт 1",
"AWPCA_Спринт 2",
"AURORA-9002",
"AURORA-9003",
"AURORA-9008"
],
[
"AURORA-9002",
"API \"свода\"",
"Ожидание разработки",
"Средний",
"Задача",
"kind-dev",
"",
"AURORA-9100",
"20/окт/26",
"Петров Пётр Петрович",
"AWPCA_Спринт 2",
"",
"",
"",
""
],
[
"AURORA-9003",
"Макет экрана",
"Backlog",
"Критичный",
"Задача",
"kind-ui",
"",
"AURORA-1359",
"2026-10-16",
"",
"AWPCA_Спринт 2",
"",
"AURORA-9001",
"",
""
],
[
"AURORA-9006",
"Задача без эпика",
"Готово",
"Средний",
"Задача",
"",
"",
"",
"10/Oct/26 12:00 PM",
"Иванов Иван Иванович",
"",
"",
"AURORA-9500",
"",
""
],
[
"not-a-key",
"мусор",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
""
]
]
}

View File

@ -0,0 +1,2 @@
Issue key,Summary,Status
AURORA-9001,Тема,Open
1 Issue key Summary Status
2 AURORA-9001 Тема Open

View File

@ -0,0 +1,28 @@
{
"model": {
"fileName": "jira-no-links.csv",
"hasSprint": false,
"links": [],
"missing": 0,
"nodes": [
{
"due": "",
"epic": "Без эпика",
"key": "AURORA-9001",
"kind": "",
"prio": "",
"sprint": "",
"status": "Open",
"stub": false,
"title": "Тема",
"who": ""
}
],
"problems": [
"в файле нет колонок связей — в фильтре Jira добавьте колонку «Связанные задачи» или экспортируйте «все поля»",
"в файле нет колонки «Спринт» — выбор спринта недоступен, все задачи считаются выбранными",
"нет колонки «Связи Фичи» — дорожки по эпикам не построить"
],
"sprints": {}
}
}

View File

@ -0,0 +1,131 @@
{
"S": {
"byDate": true,
"crit": false,
"hideEdge": [],
"hideKind": [
"org"
],
"neighbors": true,
"onlyHand": true,
"onlyLinked": false
},
"import": {
"collapsedLanes": [
"Без эпика"
],
"dateColumns": {
"2026-10-20": 360
},
"filters": {
"assignees": [
"Петров Пётр Петрович"
],
"byDate": true,
"criticalPath": false,
"hiddenEdges": [],
"hiddenKinds": [
"org"
],
"mode": "linked",
"neighbors": true,
"onlyHand": true,
"sprints": [
"AWPCA_Спринт 2"
]
},
"laneOrder": [
"Гудвилл",
"Тестовый эпик"
],
"layout": {
"AURORA-9001": {
"ry": 40,
"w": 240,
"x": 24
}
},
"layoutByDate": {
"AURORA-9001": {
"h": 144,
"ry": 40,
"w": 240,
"x": 6
}
},
"model": {
"fileName": "sprint2.csv",
"hasSprint": true,
"links": [
{
"dst": "AURORA-9002",
"src": "AURORA-9001",
"type": "блокирует начало"
},
{
"dst": "AURORA-9777",
"src": "AURORA-9002",
"type": "блокирует начало"
}
],
"missing": 1,
"nodes": [
{
"due": "2026-10-20",
"epic": "Тестовый эпик",
"key": "AURORA-9001",
"kind": "разработка",
"prio": "Высокий",
"sprint": "AWPCA_Спринт 2",
"status": "В работе",
"stub": false,
"title": "Схема свода",
"who": "Иванов Иван Иванович"
},
{
"due": "2026-10-20",
"epic": "Тестовый эпик",
"key": "AURORA-9002",
"kind": "разработка",
"prio": "Средний",
"sprint": "AWPCA_Спринт 2",
"status": "Ожидание разработки",
"stub": false,
"title": "API свода",
"who": "Петров Пётр Петрович"
},
{
"due": "",
"epic": "Нет в файле",
"key": "AURORA-9777",
"kind": "",
"prio": "",
"sprint": "",
"status": "",
"stub": true,
"title": "нет в файле",
"who": ""
}
],
"problems": [],
"sprints": {
"AWPCA_Спринт 2": 2
}
}
},
"storage": {
"graph-bydate": "1",
"graph-colw:board": "{\"2026-10-20\":360}",
"graph-crit": "0",
"graph-fold:board": "[\"Без эпика\"]",
"graph-hideedge": "[]",
"graph-hidekind": "[\"org\"]",
"graph-lanes:board": "[\"Гудвилл\",\"Тестовый эпик\"]",
"graph-neighbors": "1",
"graph-onlyhand": "1",
"graph-pos:board": "{\"AURORA-9001\":{\"x\":24,\"ry\":40,\"w\":240}}",
"graph-posdate:board": "{\"AURORA-9001\":{\"x\":6,\"ry\":40,\"w\":240,\"h\":144}}",
"graph-sprint:board": "[\"AWPCA_Спринт 2\"]",
"graph-who:board": "[\"Петров Пётр Петрович\"]"
}
}

View File

@ -0,0 +1,11 @@
{"format": "arm-ka-links/1", "exportedAt": "2026-10-11T08:00:00.000Z", "source": "sprint2.csv",
"filters": {"mode": "linked", "sprints": ["AWPCA_Спринт 2"], "assignees": ["Петров Пётр Петрович"], "onlyHand": true, "criticalPath": false, "byDate": true, "hiddenKinds": ["org"], "hiddenEdges": []},
"laneOrder": ["Гудвилл", "Тестовый эпик"], "collapsedLanes": ["Без эпика"], "dateColumns": {"2026-10-20": 360},
"nodes": [
{"key": "AURORA-9001", "title": "Схема свода", "status": "В работе", "priority": "Высокий", "kind": "разработка", "due": "2026-10-20", "assignee": "Иванов Иван Иванович", "sprint": "AWPCA_Спринт 2", "epic": "Тестовый эпик", "layout": {"x": 24, "ry": 40, "w": 240}, "layoutByDate": {"x": 6, "ry": 40, "w": 240, "h": 144}},
{"key": "AURORA-9002", "title": "API свода", "status": "Ожидание разработки", "priority": "Средний", "kind": "разработка", "due": "20.10.2026", "assignee": "Петров Пётр Петрович", "sprint": "AWPCA_Спринт 2", "epic": "Тестовый эпик", "layout": {"x": "плохо", "ry": 40}},
{"key": "AURORA-9001", "title": "дубль", "epic": "x"},
{"key": "AURORA-9500", "stub": true},
{"key": "bad key"}
],
"links": [{"from": "AURORA-9001", "to": "AURORA-9002", "type": "блокирует начало"}, {"from": "AURORA-9002", "to": "AURORA-9777", "type": "неизвестный"}, {"from": "x", "to": "AURORA-9001", "type": "связанные"}]}

View File

@ -0,0 +1,14 @@
[
{"name": "default", "filters": {}},
{"name": "sprint-2", "filters": {"sprints": ["AWPCA_Спринт 2"]}},
{"name": "sprint-2-neighbors", "filters": {"sprints": ["AWPCA_Спринт 2"], "neighbors": true}},
{"name": "who-ivanov", "filters": {"who": ["Иванов Иван Иванович"], "neighbors": true}},
{"name": "hand-linked", "filters": {"onlyHand": true, "onlyLinked": true}},
{"name": "crit", "filters": {"crit": true}},
{"name": "hide", "filters": {"hideKind": ["dev"], "hideEdge": ["rel"]}},
{"name": "empty", "filters": {"who": ["Никто Никтович"]}},
{"name": "lane-order", "filters": {}, "laneOrder": ["Гудвилл", "Без эпика", "Тестовый эпик"]},
{"name": "by-date", "filters": {"byDate": true}},
{"name": "saved-folded", "filters": {}, "saved": {"AURORA-9002": {"x": 600, "ry": 80, "w": 280, "h": 160}, "AURORA-9003": {"x": 30, "ry": 50}}, "folded": ["Гудвилл"]},
{"name": "by-date-colw", "filters": {"byDate": true}, "colWidths": {"2026-10-20": 400}, "savedDate": {"AURORA-9001": {"x": 10, "ry": 60, "w": 250}}}
]

View File

@ -0,0 +1,5 @@
{
"kind_labels": {"аналитика": "kind-analytics", "решение": "kind-decision", "актуализация": "kind-docs", "разработка": "kind-dev", "UI": "kind-ui", "тестирование": "kind-test", "организационная": "kind-org"},
"link_types": {"блокирует начало": "ASUPR_Блокер_Начала", "блокирует завершение": "ASUPR_Блокер_Завершения", "связанные": "ASUPR_Связанные"},
"status_labels": {"Готово — закрыть": "fact-done", "В работе": "fact-in-progress", "Ожидание разработки": "fact-open"}
}

View File

@ -0,0 +1,6 @@
[
{"name": "migrate-largest", "op": "migrate", "before": {"graph-pos:old.csv": "{\"AURORA-9001\":{\"x\":1,\"ry\":40}}", "graph-pos:new.csv": "{\"AURORA-9001\":{\"x\":1,\"ry\":40},\"AURORA-9002\":{\"x\":300,\"ry\":40}}", "graph-lanes:new.csv": "[\"Гудвилл\"]", "graph-fold:old.csv": "[\"x\"]"}},
{"name": "already-migrated", "op": "migrate", "before": {"graph-pos:board": "{}", "graph-pos:new.csv": "{\"AURORA-9001\":{\"x\":1,\"ry\":40}}"}},
{"name": "mode-linked", "op": "startup", "before": {"graph-mode": "linked"}},
{"name": "mode-all", "op": "startup", "before": {"graph-mode": "all"}}
]

View File

@ -0,0 +1,20 @@
{
"already-migrated": {
"graph-pos:board": "{}",
"graph-pos:new.csv": "{\"AURORA-9001\":{\"x\":1,\"ry\":40}}"
},
"migrate-largest": {
"graph-fold:old.csv": "[\"x\"]",
"graph-lanes:board": "[\"Гудвилл\"]",
"graph-lanes:new.csv": "[\"Гудвилл\"]",
"graph-pos:board": "{\"AURORA-9001\":{\"x\":1,\"ry\":40},\"AURORA-9002\":{\"x\":300,\"ry\":40}}",
"graph-pos:new.csv": "{\"AURORA-9001\":{\"x\":1,\"ry\":40},\"AURORA-9002\":{\"x\":300,\"ry\":40}}",
"graph-pos:old.csv": "{\"AURORA-9001\":{\"x\":1,\"ry\":40}}"
},
"mode-all": {
"graph-sprint:board": "[]"
},
"mode-linked": {
"graph-neighbors": "1"
}
}

View File

@ -0,0 +1,54 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import type { BoardSnapshot } from "../../api/types.ts";
import { canon } from "../../test/canon.ts";
import { dictsFromSettings } from "../dicts.ts";
import settings from "../__fixtures__/settings.json";
import scenarios from "../__fixtures__/scenarios.json";
import { fromHub } from "../model/fromHub.ts";
import { filtersOf } from "../../test/boardScenarios.ts";
import { noFilters } from "../types.ts";
import { initialSprints, initialWho, sprintItems, whoItems } from "./facets.ts";
import { emptyReason, computeVisible } from "./visible.ts";
const fx = (name: string) => readFileSync(join(import.meta.dirname, "..", "__fixtures__", name), "utf8");
const golden = JSON.parse(fx("hub-basic.expected.json"));
const { dicts } = dictsFromSettings(settings);
const model = fromHub(JSON.parse(fx("hub-basic.board.json")) as BoardSnapshot, dicts);
const TODAY = "2026-10-14";
type Sc = { name: string; filters: Record<string, unknown>; laneOrder?: string[] };
describe("выборка = links.html@3931a88", () => {
for (const sc of scenarios as Sc[]) {
it(`computeVisible: ${sc.name}`, () => {
const f = filtersOf(sc);
const vis = computeVisible(model, f, sc.laneOrder ?? [], TODAY);
const g = golden.scenarios[sc.name];
expect(canon(vis)).toEqual(g.visible);
expect(vis.nodes.length ? null : emptyReason(model, vis, f)).toEqual(g.emptyReason);
});
}
it("пустое зеркало — своя причина", () => {
const empty = fromHub(JSON.parse(fx("hub-empty.board.json")) as BoardSnapshot, dicts);
const vis = computeVisible(empty, noFilters(), [], TODAY);
expect(emptyReason(empty, vis, noFilters())).toBe(JSON.parse(fx("hub-empty.expected.json")).scenario.emptyReason);
});
it("счётчики и начальный выбор спринтов и исполнителей", () => {
// порядок как в старой странице: сначала спринты (счётчики без фильтров), затем исполнители — уже с выбранными спринтами
const check = (remembered: unknown, g: { sprintItems: unknown; whoItems: unknown; sprints: unknown; who: unknown }) => {
const f = noFilters();
expect(canon(sprintItems(model, f).items)).toEqual(g.sprintItems);
f.sprints = initialSprints(model, f, remembered);
expect(canon(f.sprints)).toEqual(g.sprints);
expect(canon(whoItems(model, f).items)).toEqual(g.whoItems);
expect(canon(initialWho(model, f, []))).toEqual(g.who);
};
check(null, golden.facets.default);
check([], golden.facets.rememberedAll);
check(["AWPCA_Спринт 3", "нет такого"], golden.facets.rememberedSome);
});
});

View File

@ -0,0 +1,40 @@
import { RELATED } from "../constants.ts";
import { dayDiff } from "../model/util.ts";
import type { VisLink, VisNode } from "../types.ts";
interface Best { len: number; slack: number; path: string[] }
// критический путь: самая длинная цепочка блокировок; при равной длине — с наименьшим запасом по плановым датам окончания
export function criticalPath(nodes: VisNode[], links: VisLink[]): { nodes: Set<string>; edges: Set<string> } {
const byKey = Object.fromEntries(nodes.map((n) => [n.key, n]));
const out: Record<string, string[]> = {};
nodes.forEach((n) => { out[n.key] = []; });
links.filter((l) => l.type !== RELATED).forEach((l) => out[l.src].push(l.dst));
const memo: Record<string, Best> = {};
// {len, slack, path} от k; запас = сумма (срок зависимой − срок блокера) по рёбрам
const best = (k: string, seen: Set<string> = new Set()): Best => {
if (memo[k]) return memo[k];
if (seen.has(k)) return { len: 0, slack: 0, path: [k] };
seen.add(k);
let r: Best = { len: 0, slack: 0, path: [k] };
out[k].forEach((d) => {
const b = best(d, seen), sl = dayDiff(byKey[k].due, byKey[d].due);
const cand = { len: b.len + 1, slack: b.slack + (sl === null ? 0 : Math.max(0, sl)), path: [k, ...b.path] };
if (cand.len > r.len || (cand.len === r.len && cand.slack < r.slack)) r = cand;
});
memo[k] = r;
return r;
};
let top: Best | null = null;
nodes.forEach((n) => {
const b = best(n.key);
if (!top || b.len > top.len || (b.len === top.len && b.slack < top.slack)) top = b;
});
const res = { nodes: new Set<string>(), edges: new Set<string>() };
const t = top as Best | null;
if (t && t.len > 0) {
t.path.forEach((k) => res.nodes.add(k));
for (let i = 0; i + 1 < t.path.length; i++) res.edges.add(t.path[i] + ">" + t.path[i + 1]);
}
return res;
}

View File

@ -0,0 +1,62 @@
import { shortName, whoKey } from "../model/util.ts";
import type { BoardModel, BoardNode, Filters } from "../types.ts";
import { passes } from "./visible.ts";
export interface FacetItem { value: string; label: string; title: string; count: string; cls: string }
export type Box = { kind: "none" } | { kind: "text"; text: string } | { kind: "multi" };
const strList = (v: unknown): string[] => Array.isArray(v) ? v.filter((s): s is string => typeof s === "string") : [];
// счётчики в выпадашках: «n/всего» — n задач с этим значением проходят два других фильтра задач, всего — в файле.
// Порядок пунктов — по n, затем по всему, затем по имени
function facetItems(model: BoardModel, f: Filters, field: (n: BoardNode) => string, skip: "sprint" | "who", labelOf: (k: string) => string, cls: (k: string) => string) {
const total: Record<string, number> = {}, shown: Record<string, number> = {};
model.nodes.filter((n) => !n.stub).forEach((n) => {
const k = field(n);
total[k] = (total[k] || 0) + 1;
if (passes(n, f, skip)) shown[k] = (shown[k] || 0) + 1;
});
const keys = Object.keys(total).sort((a, b) => Number(a === "") - Number(b === "") || (shown[b] || 0) - (shown[a] || 0) || total[b] - total[a] || a.localeCompare(b, "ru"));
const items: FacetItem[] = keys.map((k) => ({
value: k, label: labelOf(k),
title: `${labelOf(k)}: ${shown[k] || 0} из ${total[k]} задач проходят остальные фильтры`,
count: `${shown[k] || 0}/${total[k]}`, cls: cls(k) + (shown[k] ? "" : " zero"),
}));
return { total, items };
}
export const sprintItems = (model: BoardModel, f: Filters) =>
facetItems(model, f, (n) => n.sprint || "", "sprint", (k) => k || "без спринта", (k) => (k ? "" : "none"));
export const whoItems = (model: BoardModel, f: Filters) =>
facetItems(model, f, (n) => whoKey(n.who), "who", (k) => (k ? shortName(k) : "не назначен"), (k) => (k ? "" : "none"));
// что показывать вместо выпадающего списка спринтов
export function sprintBox(model: BoardModel): Box {
if (!model.nodes.length) return { kind: "none" };
if (!model.hasSprint) return { kind: "text", text: "колонки «Спринт» нет — показаны все задачи" };
return { kind: "multi" };
}
// то же для исполнителей
export function whoBox(model: BoardModel, f: Filters): Box {
if (!model.nodes.length) return { kind: "none" };
const names = Object.keys(whoItems(model, f).total);
if (!names.length || (names.length === 1 && names[0] === "")) return { kind: "text", text: names.length ? "все задачи без исполнителя" : "колонки «Исполнитель» нет" };
return { kind: "multi" };
}
// список спринтов: по умолчанию выбран самый наполненный; «без спринта» — отдельный пункт; выбор помнится; пустой выбор = все спринты
export function initialSprints(model: BoardModel, f: Filters, remembered: unknown): Set<string> {
if (!model.nodes.length || !model.hasSprint) return new Set();
const { total } = sprintItems(model, f);
const sel = new Set(strList(remembered).filter((n) => total[n] !== undefined));
if (!sel.size && !(Array.isArray(remembered) && remembered.length === 0)) { // пустой сохранённый выбор — осознанное «все спринты»
const top = Object.entries(total).filter(([n]) => n).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))[0];
if (top) sel.add(top[0]);
}
return sel;
}
// список исполнителей: чистый фильтр, на доске только их задачи; соседей добавляет кнопка «+ соседи по связям»
export function initialWho(model: BoardModel, f: Filters, remembered: unknown): Set<string> {
if (!model.nodes.length || whoBox(model, f).kind !== "multi") return new Set();
const { total } = whoItems(model, f);
return new Set(strList(remembered).filter((w) => total[w] !== undefined));
}

View File

@ -0,0 +1,20 @@
import { RELATED } from "../constants.ts";
import { dayDiff, fmtDate, notStarted, shortName, whoKey } from "../model/util.ts";
import type { ProblemItem, Problems, VisLink, VisNode } from "../types.ts";
// точки риска по блокирующим связям видимой части доски; today — ГГГГ-ММ-ДД
export function findProblems(nodes: VisNode[], links: VisLink[], today: string): Problems {
const byKey = Object.fromEntries(nodes.map((n) => [n.key, n]));
const dates: ProblemItem[] = [], unassigned: ProblemItem[] = [], late: ProblemItem[] = [], hand: ProblemItem[] = [];
links.filter((l) => l.type !== RELATED).forEach((l) => {
const a = byKey[l.src], b = byKey[l.dst];
if (a.stub || b.stub) return;
const sl = dayDiff(a.due, b.due);
if (sl !== null && sl <= 0) dates.push({ a, b, text: sl === 0 ? `сроки совпадают (${fmtDate(a.due)}): запаса нет` : `блокер должен закончиться ${fmtDate(a.due)}, а зависимая — уже ${fmtDate(b.due)}` });
if (!whoKey(a.who)) unassigned.push({ a, b, text: whoKey(b.who) ? `${shortName(b.who)} ждёт задачу без исполнителя` : "блокер без исполнителя" });
const left = dayDiff(today, b.due);
if (notStarted(a.status) && left !== null && left <= 7) late.push({ a, b, text: `блокер в статусе «${a.status}», а до срока зависимой ${left < 0 ? "срок прошёл" : left + " дн."}` });
if (l.hand && whoKey(a.who) && whoKey(b.who)) hand.push({ a, b, text: `${shortName(b.who)} ждёт ${shortName(a.who)}` });
});
return { dates, unassigned, late, hand };
}

View File

@ -0,0 +1,59 @@
import { KIND, NO_EPIC, NOT_IN_FILE, NOT_IN_HUB, RELATED } from "../constants.ts";
import { whoKey } from "../model/util.ts";
import type { BoardModel, BoardNode, Filters, Link, VisLink, VisNode, Visible } from "../types.ts";
import { criticalPath } from "./critical.ts";
import { findProblems } from "./problems.ts";
// фильтры задач — пересечение спринта, исполнителя и вида работы; skip — какой из них не учитывать (для счётчиков в выпадашках).
// Заглушки «нет в файле» фильтры не проходят: они попадают на доску только как соседи по связям
export const passes = (x: BoardNode, f: Filters, skip?: "sprint" | "who" | "kind"): boolean => !x.stub
&& (skip === "sprint" || f.sprints.size === 0 || f.sprints.has(x.sprint))
&& (skip === "who" || f.who.size === 0 || f.who.has(whoKey(x.who)))
&& (skip === "kind" || !f.hideKind.has(KIND[x.kind] || "org"));
export const edgeClass = (l: Link): "rel" | "block" => (l.type === RELATED ? "rel" : "block");
// доска: ядро = задачи, прошедшие фильтры; контекст = их соседи по связям (если включён); стрелки = все связи между карточками на доске
// минус скрытые типы и, при «передачах», минус связи внутри одного человека; «скрыть без связей» убирает карточки без оставшихся стрелок.
// removed — сколько карточек/стрелок убрал каждый шаг, для строки статуса и объяснения пустой доски
export function computeVisible(model: BoardModel, f: Filters, laneOrder: string[], today: string): Visible {
const all = model.nodes, byKey = Object.fromEntries(all.map((x) => [x.key, x]));
const isHand = (l: Link) => l.type !== RELATED && whoKey(byKey[l.src].who) !== whoKey(byKey[l.dst].who);
const core = new Set(all.filter((x) => passes(x, f)).map((x) => x.key));
let keep = new Set(core);
if (f.neighbors) model.links.forEach((l) => { if (core.has(l.src) || core.has(l.dst)) { keep.add(l.src); keep.add(l.dst); } });
const removed = { core: core.size, ctx: keep.size - core.size, edge: 0, loose: 0 };
let rawLinks = model.links.filter((l) => keep.has(l.src) && keep.has(l.dst));
const allLinks = rawLinks.length;
if (f.hideEdge.size) rawLinks = rawLinks.filter((l) => !f.hideEdge.has(edgeClass(l)));
if (f.onlyHand) rawLinks = rawLinks.filter(isHand);
removed.edge = allLinks - rawLinks.length;
if (f.onlyLinked) {
const ends = new Set(rawLinks.flatMap((l) => [l.src, l.dst])), before = keep.size;
keep = new Set([...keep].filter((k) => ends.has(k)));
removed.loose = before - keep.size;
}
const links: VisLink[] = rawLinks.map((l) => ({ ...l, hand: isHand(l), crit: false }));
// ядро сплошной рамкой, соседи пунктиром
const nodes: VisNode[] = all.filter((x) => keep.has(x.key)).map((x) => ({ ...x, dim: !core.has(x.key), focus: false, crit: false }));
const crit = f.crit ? criticalPath(nodes, links) : { nodes: new Set<string>(), edges: new Set<string>() };
nodes.forEach((x) => { x.crit = crit.nodes.has(x.key); });
links.forEach((l) => { l.crit = crit.edges.has(l.src + ">" + l.dst); });
// дорожки по эпикам: по алфавиту, служебные внизу, затем сохранённый порядок пользователя
const byEpic: Record<string, string[]> = {};
nodes.forEach((x) => (byEpic[x.epic] = byEpic[x.epic] || []).push(x.key));
const tail = (name: string) => name === NO_EPIC || name === NOT_IN_FILE || name === NOT_IN_HUB;
const lanes = Object.entries(byEpic).sort((a, b) => Number(tail(a[0])) - Number(tail(b[0])) || a[0].localeCompare(b[0], "ru")).map(([name, keys]) => ({ name, keys }));
if (laneOrder.length) lanes.sort((a, b) => { const ia = laneOrder.indexOf(a.name), ib = laneOrder.indexOf(b.name); return (ia < 0 ? 1e9 : ia) - (ib < 0 ? 1e9 : ib); });
return { nodes, links, lanes, removed, problems: findProblems(nodes, links, today) };
}
export function emptyReason(model: BoardModel, vis: Visible, f: Filters): string {
const r = vis.removed;
if (!model.nodes.some((n) => !n.stub)) return model.emptyText || (model.stubLane === NOT_IN_HUB ? "В зеркале хаба нет задач." : "В файле нет задач.");
if (!r.core) {
const active = [f.sprints.size ? "спринт" : "", f.who.size ? "исполнитель" : "", f.hideKind.size ? "виды работ в легенде" : ""].filter(Boolean);
return `Фильтры не оставили ни одной задачи (${active.join(", ")}). Ослабьте один из них: «Снять выбор» в выпадашке или щелчок по зачёркнутому виду работы.`;
}
if (r.loose) return `Все ${r.loose} задач скрыты кнопкой «скрыть без связей»: ${f.onlyHand ? "передач между людьми на доске нет" : "видимых стрелок у них нет"}. Отключите её${f.onlyHand ? " или «передачи»" : f.hideEdge.size ? " или верните тип связи в легенде" : ""}.`;
return "Нет задач для показа.";
}

162
web/src/board/board.css Normal file
View File

@ -0,0 +1,162 @@
/* 3. Закреплённая шапка: название, файл, статистика, кнопка проблем, меню; под ней строка управления и легенда */
.sticky{position:sticky;top:env(safe-area-inset-top,0px);z-index:10;background:var(--bg);padding-block:12px 8px;margin:0 0 8px;box-shadow:0 1px 0 var(--line)}
.top{display:flex;align-items:center;gap:6px 14px;flex-wrap:wrap;margin:0 0 8px;min-height:34px}
.src{display:inline-flex;align-items:baseline;gap:8px;min-width:0}
.file-name{font-size:14px;color:var(--muted);max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.stats{font-size:14px;color:var(--muted);font-variant-numeric:tabular-nums;white-space:nowrap}
.stats b{color:var(--fg);font-weight:500}
/* 5. Строка управления, предупреждения о файле, легенда-фильтр */
.bar{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:0 0 8px}
.notice{display:flex;flex-wrap:wrap;gap:4px 16px;font-size:13.5px;color:var(--p-high);margin:0 0 6px}
.legend{display:flex;flex-wrap:wrap;align-items:center;gap:2px 12px;margin:0;font-size:13px;color:var(--muted)}
.legend span{display:inline-flex;align-items:center;gap:6px}
.legend .sep{width:1px;height:14px;background:var(--line)}
.legend .hint{margin-left:auto}
.lg{font:inherit;font-size:13px;color:var(--muted);background:none;border:0;padding:2px 5px;border-radius:4px;cursor:pointer;display:inline-flex;align-items:center;gap:6px}
.lg:hover,.lg:focus-visible{background:var(--soft);color:var(--fg);outline:none}
.lg.off{opacity:.45;text-decoration:line-through}
.sw{width:10px;height:10px;border-radius:2px;display:inline-block}
.ln{width:26px;height:0;border-top:2px solid var(--edge-strong);display:inline-block}
.ln.dash{border-top-style:dashed;border-top-color:var(--edge)}
.ln.hand{border-top-color:var(--guide)}
.ln.crit{border-top-width:4px;border-top-color:var(--guide)}
.out-sw{width:16px;height:11px;border:1.5px dashed var(--muted);border-radius:3px;display:inline-block}
/* 6. Доска: прокрутка только по горизонтали (вертикальная — у страницы), дорожки по эпикам, карточки абсолютные */
.stage{display:flex;gap:10px;align-items:stretch}
.wrap{flex:1 1 auto;min-width:0;overflow-x:auto;overflow-y:hidden;border:1px solid var(--line);border-radius:8px;background:var(--card);user-select:none;-webkit-user-select:none}
.board{position:relative;min-width:max(100%,max-content)}
svg.edges,svg.guides{position:absolute;inset:0;width:100%;height:100%;pointer-events:none}
svg.guides{z-index:3}
svg.guides line{stroke:var(--guide);stroke-width:1;stroke-dasharray:4 3}
.empty{padding:40px 16px;color:var(--muted);text-align:center}
.bar>span:empty{display:none}
#hubState{white-space:normal;min-width:0}
.login{max-width:560px;margin:48px auto;padding:20px 24px;background:var(--panel);border:1px solid var(--line);border-radius:8px}
.login h2{margin:0 0 8px;font-size:17px;font-weight:600}
.login p{margin:0 0 12px;font-size:14px;color:var(--muted)}
.login p.err{color:var(--p-crit)}
.login code{font-family:var(--font-mono);font-size:13px;color:var(--fg)}
.login form{display:flex;gap:8px;margin:0 0 12px}
.login input{flex:1;min-width:0;font:inherit;font-size:14px;height:34px;box-sizing:border-box;padding:0 10px;color:var(--fg);background:var(--bg);border:1px solid var(--line);border-radius:6px}
.login input:focus-visible{border-color:var(--accent);outline:none}
.lane{position:absolute;left:0;right:0;border-top:1px solid var(--line)}
.lane:nth-child(even of .lane){background:color-mix(in srgb,var(--soft) 45%,transparent)}
.lane.folded{background:color-mix(in srgb,var(--soft) 80%,transparent)}
.lane.target{box-shadow:inset 0 3px 0 var(--accent)}
.lane.target-after{box-shadow:inset 0 -3px 0 var(--accent)}
.lane.dragging{opacity:.6}
.lane-head{position:absolute;left:8px;top:8px;display:flex;align-items:center;gap:4px;z-index:1}
.fold{width:18px;height:18px;border:0;background:none;color:var(--muted);cursor:pointer;padding:0;border-radius:4px;display:inline-flex;align-items:center;justify-content:center;font-size:0}
.fold::before{content:"";border:5px solid transparent;border-top:6px solid currentColor;border-bottom:0;transition:transform .15s}
.fold:hover{background:var(--soft);color:var(--fg)}
.lane.folded .fold::before{transform:rotate(-90deg)}
.lane-name{font-size:12.5px;letter-spacing:.06em;text-transform:uppercase;color:var(--muted);font-weight:500;cursor:grab;padding:2px 4px;border-radius:4px;user-select:none;touch-action:none}
.lane-name:hover{background:var(--soft);color:var(--fg)}
.lane-count{display:none;font-size:12.5px;color:var(--muted);margin-left:4px}
.lane.folded .lane-count{display:inline}
.lane-tools{display:none;gap:2px}
.lane:hover .lane-tools{display:inline-flex}
.lane-tools button{font:inherit;font-size:12px;line-height:1;color:var(--muted);background:var(--card);border:1px solid var(--line);border-radius:4px;width:22px;height:22px;cursor:pointer;padding:0}
.lane-tools button:hover{border-color:var(--accent);color:var(--accent)}
.lane-tools button:disabled{opacity:.35;cursor:default}
/* режим «по датам»: колонки по плановым датам окончания, шапка колонки над дорожками; карточки двигаются внутри своей колонки */
.dcol{position:absolute;top:0;border-left:1px solid var(--line);box-sizing:border-box;pointer-events:none}
.dcol:first-of-type{border-left:0}
.dcol.past{background:color-mix(in srgb,var(--p-crit) 5%,transparent)}
.dcol.today{background:color-mix(in srgb,var(--accent) 7%,transparent)}
/* полоса дат над доской: липнет под шапкой страницы, по горизонтали двигается вместе с доской (transform по scrollLeft) */
.dates-bar{position:sticky;top:calc(var(--head-h,0px) + env(safe-area-inset-top,0px));z-index:9;background:var(--bg)}
.dates-in{overflow:hidden;border:1px solid var(--line);border-bottom:0;border-radius:8px 8px 0 0;background:var(--card);height:36px;box-sizing:border-box}
.dates-track{position:relative;height:100%}
.dlabel{position:absolute;top:9px;font-size:12.5px;letter-spacing:.06em;text-transform:uppercase;color:var(--fg);font-weight:600;white-space:nowrap}
.dlabel .n{font-weight:400;color:var(--muted);margin-left:6px;font-variant-numeric:tabular-nums}
.dlabel.past{color:var(--p-crit)}
.dlabel.today{color:var(--accent)}
.dlabel.none{color:var(--muted);font-weight:500}
.stage.bydate .wrap{border-top-left-radius:0;border-top-right-radius:0}
.dsplit{position:absolute;top:0;bottom:0;width:11px;margin-left:-5px;cursor:col-resize;z-index:1}
.dsplit::after{content:"";position:absolute;left:5px;top:6px;bottom:6px;width:1px;background:var(--line)}
.dsplit:hover::after,.dsplit.on::after{background:var(--accent);width:3px;left:4px;border-radius:2px}
.dates-in.resizing{cursor:col-resize}
/* горизонтальная прокрутка доски, прилипшая к низу окна: полоса всегда видна, скроллбар нарисован явно (на macOS системный прячется) */
.hscroll{position:sticky;bottom:0;z-index:8;overflow-x:scroll;overflow-y:hidden;height:14px;background:var(--bg);padding-top:2px;box-sizing:border-box;scrollbar-width:thin;scrollbar-color:var(--edge) var(--soft)}
.hscroll>div{height:1px}
.hscroll::-webkit-scrollbar{height:12px}
.hscroll::-webkit-scrollbar-track{background:var(--soft);border-radius:6px}
.hscroll::-webkit-scrollbar-thumb{background:var(--edge);border-radius:6px;border:2px solid var(--soft)}
.hscroll::-webkit-scrollbar-thumb:hover{background:var(--accent)}
/* карточка: текст выделяется, тянуть можно только за рамку (появляется при наведении) и за уголок */
.card{position:absolute;width:240px;height:120px;box-sizing:border-box;background:var(--card);border:1px solid var(--line);border-radius:6px;padding:12px 14px;display:flex;flex-direction:column;gap:4px;overflow:hidden;cursor:default;touch-action:none}
.card:hover{border-color:var(--accent)}
.card.out{border-style:dashed;opacity:.7}
.card.crit{box-shadow:0 0 0 2px var(--guide)}
.card.focus{box-shadow:0 0 0 2px var(--accent)}
.card.drag{cursor:grabbing;border-color:var(--accent);box-shadow:0 6px 18px rgba(0,0,0,.18);z-index:2}
.card .title,.card .state,.card .who,.card .meta,.card .key,.card .sprint-tag{user-select:text;-webkit-user-select:text;cursor:text;touch-action:auto}
.card .key{cursor:pointer}
.card .meta{cursor:default}
.card .grip{position:absolute;background:var(--accent);opacity:0;cursor:grab;z-index:1;transition:opacity .12s}
.card .grip.t{left:0;right:0;top:0;height:8px;border-radius:5px 5px 0 0}
.card .grip.b{left:0;right:14px;bottom:0;height:8px;border-radius:0 0 0 5px}
.card .grip.l{left:0;top:0;bottom:0;width:8px;border-radius:5px 0 0 5px}
.card .grip.r{right:0;top:0;bottom:14px;width:8px;border-radius:0 5px 0 0}
.card:hover .grip,.card.drag .grip{opacity:.85}
.card.drag .grip{cursor:grabbing}
.card .rsz{position:absolute;right:0;bottom:0;width:14px;height:14px;cursor:nwse-resize;background:linear-gradient(135deg,transparent 50%,var(--line) 50%);border-bottom-right-radius:5px;z-index:2}
.card:hover .rsz{background:linear-gradient(135deg,transparent 50%,var(--accent) 50%)}
.head{display:flex;align-items:center;justify-content:space-between;gap:6px;min-width:0}
.key{font-family:var(--font-mono);font-size:13.5px;font-weight:500;color:var(--accent);text-decoration:none;flex:0 0 auto}
.sprint-tag{font-size:11px;line-height:1;padding:4px 7px;border-radius:10px;background:var(--soft);color:var(--muted);max-width:55%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:0 1 auto}
.sprint-tag.sel{background:var(--accent);color:var(--accent-fg)}
.sprint-tag.none{background:transparent;border:1px dashed var(--line)}
.title{font-size:14.5px;line-height:19px;min-width:0;flex:1 1 auto;overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2}
.meta{display:flex;align-items:center;gap:8px;font-size:12.5px;color:var(--muted);white-space:nowrap;flex:0 0 auto}
.kind{display:inline-flex;align-items:center;gap:4px}
.kind i{width:8px;height:8px;border-radius:2px;display:inline-block}
.prio{font-weight:600}
/* состояние задачи (статус, срок, исполнитель) — отдельный блок под чертой, основным цветом: это главное после названия */
.state,.who{font-size:13px;flex:0 0 auto;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:var(--fg)}
.state{display:flex;align-items:center;gap:6px;margin-top:4px;padding-top:7px;border-top:1px solid var(--line)}
.status{font-size:12.5px;line-height:1;padding:4px 7px;border-radius:10px;background:var(--soft);min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.due{font-variant-numeric:tabular-nums;font-weight:600;flex:0 0 auto}
.who::before{content:"";display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--accent);margin-right:5px;vertical-align:1px}
.who.none{color:var(--muted);font-style:italic}
.who.none::before{background:transparent;border:1px solid var(--muted)}
/* 7. Панель проблем справа от доски, липнет под шапкой и прокручивается внутри себя */
.drawer{flex:0 0 340px;max-width:40%;background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:10px 12px;overflow:auto;font-size:14px;box-sizing:border-box;align-self:flex-start;position:sticky;top:calc(var(--head-h,0px) + 8px);max-height:calc(100vh - var(--head-h,0px) - 24px)}
.drawer-head{display:flex;justify-content:space-between;align-items:center;gap:8px}
.drawer-head b{font-weight:600}
.drawer h4{font-size:12px;letter-spacing:.06em;text-transform:uppercase;color:var(--muted);font-weight:500;margin:12px 0 4px}
.drawer ul{margin:0;padding-left:18px}
.drawer li{margin:3px 0}
.drawer .k{font-family:var(--font-mono);font-size:13px;color:var(--accent);text-decoration:none}
.drawer .ok{color:var(--k-test);margin:8px 0 0}
.drawer .why{color:var(--muted)}
/* 8. Экспорт JSON и таблица связей (включаются из меню «⋯») */
.export{background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:10px 12px;margin:0 0 8px;display:flex;flex-direction:column;gap:8px}
.export textarea{font-family:var(--font-mono);font-size:11.5px;width:100%;box-sizing:border-box;height:180px;border:1px solid var(--line);border-radius:6px;background:var(--card);color:var(--fg);padding:8px;resize:vertical}
details{margin-top:12px}
summary{cursor:pointer;color:var(--muted);font-size:14px}
.tbl{max-width:100%;overflow-x:auto;margin-top:8px}
table{border-collapse:collapse;font-size:14px;min-width:520px}
th,td{text-align:left;padding:6px 10px;border-bottom:1px solid var(--line);vertical-align:top}
th{font-size:11px;letter-spacing:.06em;text-transform:uppercase;color:var(--muted);font-weight:500}
td.k{font-family:var(--font-mono);font-size:12px;white-space:nowrap}
/* 9. Движение и узкие экраны */
@media (prefers-reduced-motion:no-preference){.card{transition:border-color .15s}}
@media (prefers-reduced-motion:reduce){.card .grip,.fold::before{transition:none}}
@media (max-width:900px){.stage{flex-direction:column}.drawer{flex:none;max-width:100%;max-height:none;position:static}}
@media (max-width:640px){.sticky{position:static}.dates-bar{position:static}.file-name{max-width:100%}}
/* «Открыть карточку» в заголовке карточки (R-1) */
.card .open-card{margin-left:6px;color:var(--muted);text-decoration:none;font-size:13px}
.card .open-card:hover,.card .open-card:focus-visible{color:var(--accent);outline:none}
/* слой измерения: карточки шириной CW с высотой по содержимому, невидим */
.measure{position:absolute;left:-100000px;top:0;visibility:hidden;pointer-events:none}
.measure .card{position:static;height:auto}
.measure .card .title{-webkit-line-clamp:unset}
.top h1{font-size:15px;font-weight:600;margin:0}

View File

@ -0,0 +1,114 @@
import { useState } from "react";
import { fireEvent, screen, within } from "@testing-library/react";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it, vi } from "vitest";
import type { BoardSnapshot } from "../../api/types.ts";
import { renderWithProviders } from "../../test/render.tsx";
import { computeVisible } from "../analysis/visible.ts";
import { dictsFromSettings } from "../dicts.ts";
import settings from "../__fixtures__/settings.json";
import { fromHub } from "../model/fromHub.ts";
import { noFilters } from "../types.ts";
import { BoardCanvas, type LayoutStore } from "./BoardCanvas.tsx";
const fx = (name: string) => readFileSync(join(import.meta.dirname, "..", "__fixtures__", name), "utf8");
const { dicts } = dictsFromSettings(settings);
const model = fromHub(JSON.parse(fx("hub-basic.board.json")) as BoardSnapshot, dicts);
const store: LayoutStore = { laneOrder: [], folded: new Set(), colWidths: {}, version: 0 };
function draw(over: Partial<Parameters<typeof BoardCanvas>[0]> = {}) {
const f = { ...noFilters(), ...(over.byDate ? { byDate: true } : {}) };
const vis = computeVisible(model, f, [], "2026-10-14");
const props = { vis, byDate: false, snap: true, today: "2026-10-14", store, sprintSel: new Set<string>(), emptyText: "пусто",
onToggleLane: vi.fn(), onMoveLane: vi.fn(), onColWidth: vi.fn(), ...over };
renderWithProviders(<BoardCanvas {...props} />);
return props;
}
const board = () => document.querySelector(".board") as HTMLElement;
describe("BoardCanvas", () => {
it("дорожки по эпикам и карточки задач с заглушкой", () => {
draw();
const lanes = [...board().querySelectorAll(".lane-name")].map((e) => e.textContent);
expect(lanes).toEqual(["Гудвилл", "Тестовый эпик", "AURORA-9999", "Без эпика"]);
expect(board().querySelectorAll(".card[data-key]")).toHaveLength(9);
});
it("заглушка соседа вне зеркала — на служебной дорожке с подписью", () => {
const vis = computeVisible(model, { ...noFilters(), who: new Set(["Иванов Иван Иванович"]), neighbors: true }, [], "2026-10-14");
draw({ vis });
expect([...board().querySelectorAll(".lane-name")].map((e) => e.textContent)).toContain("Нет в зеркале");
expect(within(board()).getByText("нет в зеркале")).toBeInTheDocument();
});
it("ключ ведёт в Jira, ⓘ — в карточку приложения; у заглушки ⓘ нет", () => {
draw({ vis: computeVisible(model, { ...noFilters(), who: new Set(["Иванов Иван Иванович"]), neighbors: true }, [], "2026-10-14") });
const card = board().querySelector('.card[data-key="AURORA-9001"]') as HTMLElement;
expect(within(card).getByRole("link", { name: "AURORA-9001" })).toHaveAttribute("href", "https://sdlc.go.rshbank.ru/jira/browse/AURORA-9001");
expect(within(card).getByRole("link", { name: "Открыть карточку AURORA-9001" })).toHaveAttribute("href", "/issues/AURORA-9001");
const stub = board().querySelector('.card[data-key="AURORA-9500"]') as HTMLElement;
expect(within(stub).queryByRole("link", { name: /Открыть карточку/ })).toBeNull();
});
it("стрелки: по одной на видимую связь, «связанные» пунктиром, блокирующие со стрелкой", () => {
draw();
const paths = [...document.querySelectorAll("svg.edges > path")];
expect(paths).toHaveLength(6);
expect(paths.filter((p) => p.getAttribute("stroke-dasharray") === "5 4")).toHaveLength(1);
expect(paths.filter((p) => p.getAttribute("marker-end"))).toHaveLength(5);
});
it("свёртка и перестановка дорожек — через колбэки", () => {
const p = draw();
const lane = [...board().querySelectorAll(".lane")].find((l) => l.textContent?.includes("Гудвилл")) as HTMLElement;
fireEvent.click(within(lane).getByTitle("Свернуть или развернуть дорожку"));
expect(p.onToggleLane).toHaveBeenCalledWith("Гудвилл");
fireEvent.click(within(lane).getByTitle("Ниже"));
expect(p.onMoveLane).toHaveBeenCalledWith("Гудвилл", 1);
});
it("свёрнутая дорожка прячет свои карточки", () => {
draw({ store: { ...store, folded: new Set(["Гудвилл"]) } });
expect(board().querySelector('.card[data-key="AURORA-9004"]')).toBeNull();
expect(board().querySelector(".lane.folded")).not.toBeNull();
});
it("«по датам»: колонки и подписи дат, двойной щелчок по границе — автоматическая ширина", () => {
const p = draw({ byDate: true });
expect(board().querySelectorAll(".dcol").length).toBeGreaterThan(0);
expect(screen.getByText("16.10")).toBeInTheDocument();
fireEvent.doubleClick(document.querySelector(".dsplit") as HTMLElement);
expect(p.onColWidth).toHaveBeenCalledWith(expect.any(String), null);
});
it("пустая выборка — текст причины", () => {
draw({ vis: computeVisible(model, { ...noFilters(), who: new Set(["Никто"]) }, [], "2026-10-14"), emptyText: "Фильтры не оставили" });
expect(screen.getByText("Фильтры не оставили")).toBeInTheDocument();
});
it("сужение выборки при смене фильтра не роняет холст (карточки старой раскладки пропускаются)", () => {
const wide = computeVisible(model, noFilters(), [], "2026-10-14");
const narrow = computeVisible(model, { ...noFilters(), who: new Set(["Иванов Иван Иванович"]) }, [], "2026-10-14");
expect(narrow.nodes.length).toBeLessThan(wide.nodes.length);
function Harness() {
const [vis, setVis] = useState(wide);
return (
<>
<button onClick={() => setVis(narrow)}>сузить</button>
<BoardCanvas vis={vis} byDate={false} snap today="2026-10-14" store={store} sprintSel={new Set<string>()} emptyText="пусто"
onToggleLane={vi.fn()} onMoveLane={vi.fn()} onColWidth={vi.fn()} />
</>
);
}
renderWithProviders(<Harness />);
expect(board().querySelectorAll(".card[data-key]")).toHaveLength(wide.nodes.length);
fireEvent.click(screen.getByText("сузить"));
expect(board().querySelectorAll(".card[data-key]").length).toBeLessThanOrEqual(narrow.nodes.length);
});
it("полоса дат получает ширину обёртки доски", () => {
draw({ byDate: true });
expect((document.querySelector(".dates-in") as HTMLElement).style.width).toMatch(/px$|^0/);
});
});

View File

@ -0,0 +1,185 @@
import { useCallback, useLayoutEffect, useMemo, useRef, useState, type PointerEvent, type ReactNode } from "react";
import { CW, GRID, MINH, MINW, TOP } from "../constants.ts";
import { alignEdges } from "../layout/align.ts";
import { clampToCol, computeLayout, measuredHeight, place, type Layout, type Placed, type RelBox } from "../layout/layout.ts";
import { saveLayout, savedLayout } from "../storage.ts";
import type { Visible } from "../types.ts";
import { Card } from "./Card.tsx";
import { DatesBar } from "./DatesBar.tsx";
import { Edges, Guides } from "./Edges.tsx";
import { Lane } from "./Lane.tsx";
import "../board.css";
export interface LayoutStore { laneOrder: string[]; folded: Set<string>; colWidths: Record<string, number>; version: number }
export interface BoardCanvasProps {
vis: Visible; byDate: boolean; snap: boolean; today: string; store: LayoutStore; sprintSel: Set<string>;
emptyText: string;
saveRef?: { current: (() => void) | null };
onToggleLane(name: string): void;
onMoveLane(name: string, to: number): void;
onColWidth(date: string, w: number | null): void;
children?: ReactNode;
}
export function BoardCanvas({ vis, byDate, snap, today, store, sprintSel, emptyText, onToggleLane, onMoveLane, onColWidth, saveRef, children }: BoardCanvasProps) {
const boardRef = useRef<HTMLDivElement>(null);
const measureRef = useRef<HTMLDivElement>(null);
const wrapRef = useRef<HTMLDivElement>(null);
const hscrollRef = useRef<HTMLDivElement>(null);
const datesInRef = useRef<HTMLDivElement>(null);
const [layout, setLayout] = useState<Layout | null>(null);
const [rel, setRel] = useState<Record<string, RelBox>>({});
const [guides, setGuides] = useState<{ x: number | null; y: number | null } | null>(null);
const [scroll, setScroll] = useState({ left: 0, wide: false, wrapW: 0, scrollW: 0 });
const [dragTarget, setDragTarget] = useState<{ from: number; to: number } | null>(null);
// раскладка как render() links.html: при новой выборке, режиме и ширинах колонок — заново, с сохранённой из хранилища
useLayoutEffect(() => {
if (!vis.nodes.length) { setLayout(null); setRel({}); return; }
const els = new Map<string, HTMLElement>();
measureRef.current?.querySelectorAll<HTMLElement>(".card[data-key]").forEach((el) => els.set(el.dataset.key!, el));
const measure = (key: string) => {
const el = els.get(key), oh = el?.offsetHeight ?? 0;
return { h: measuredHeight(oh), other: oh - (el?.querySelector<HTMLElement>(".title")?.offsetHeight ?? 0) };
};
const lay = computeLayout(vis, { byDate, colWidths: store.colWidths, saved: savedLayout(byDate), today, measure });
setLayout(lay);
setRel(lay.rel);
}, [vis, byDate, store.colWidths, store.version, today]);
const placed = useMemo<Placed | null>(() => (layout ? place(layout, rel, store.folded) : null), [layout, rel, store.folded]);
const live = useRef({ placed, rel, layout });
useLayoutEffect(() => { live.current = { placed, rel, layout }; });
// экспорт JSON сохраняет текущую раскладку (в том числе автоматическую) перед выгрузкой
useLayoutEffect(() => {
if (!saveRef) return;
saveRef.current = () => { const { rel: r, layout: lay } = live.current; if (lay) saveLayout(r, lay.colBounds, byDate); };
return () => { saveRef.current = null; };
}, [saveRef, byDate]);
// перетаскивание за рамку и растяжка за уголок (строки 753–778): направляющие, сетка, не выше своей дорожки
const onCardDown = useCallback((ev: PointerEvent<HTMLDivElement>, key: string) => {
const t = ev.target as HTMLElement;
if (t.closest("a")) return;
const resizing = !!t.closest(".rsz");
if (!resizing && !t.closest(".grip")) return; // остальное — текст, его можно выделять
const { placed: pl, layout: lay } = live.current;
if (!pl || !lay || !pl.pos[key]) return;
ev.preventDefault();
const el = ev.currentTarget;
el.classList.add("drag");
t.setPointerCapture(ev.pointerId); // захват на нажатом элементе, а не на карточке: иначе dblclick по уголку уходит на карточку (Chromium)
const q0 = { ...pl.pos[key] }, start = { x: ev.clientX, y: ev.clientY };
let cur = live.current.rel;
const move = (e: globalThis.PointerEvent) => {
const dx = e.clientX - start.x, dy = e.clientY - start.y;
const others = live.current.placed!.pos, r0 = cur[key];
let next: RelBox;
if (resizing) {
const g = alignEdges(others, key, { x: q0.x, y: q0.y, w: Math.max(MINW, q0.w + dx), h: Math.max(MINH, q0.h + dy) }, true);
let { w, h } = g;
if (snap) { w = Math.round(w / GRID) * GRID; h = Math.round(h / GRID) * GRID; }
const cc = clampToCol(lay.colBounds, key, r0.x, Math.max(MINW, w));
next = { ...r0, w: cc.w, h: Math.max(MINH, h), auto: false };
setGuides({ x: g.guideX, y: g.guideY });
} else {
const g = alignEdges(others, key, { x: Math.max(0, q0.x + dx), y: Math.max(0, q0.y + dy), w: q0.w, h: q0.h }, false);
let { x, y } = g;
if (snap && !g.snappedX) x = Math.round(x / GRID) * GRID;
if (snap && !g.snappedY) y = Math.round(y / GRID) * GRID;
const laneY = live.current.placed!.lanes[lay.laneOf[key]].y;
next = { ...r0, x: clampToCol(lay.colBounds, key, Math.max(0, x), r0.w).x, ry: Math.max(TOP, y - laneY) };
setGuides({ x: g.guideX, y: g.guideY });
}
cur = { ...cur, [key]: next };
setRel(cur);
};
const stop = () => {
el.classList.remove("drag");
el.removeEventListener("pointermove", move); el.removeEventListener("pointerup", stop); el.removeEventListener("pointercancel", stop);
setGuides(null);
saveLayout(cur, lay.colBounds, byDate);
};
el.addEventListener("pointermove", move); el.addEventListener("pointerup", stop); el.addEventListener("pointercancel", stop);
}, [snap, byDate]);
// двойной щелчок по уголку: ширина CW (в пределах колонки даты), высота по содержимому (строка 750)
const onAutoSize = useCallback((key: string) => {
const { layout: lay, rel: r } = live.current;
if (!lay) return;
const el = measureRef.current?.querySelector<HTMLElement>(`.card[data-key="${CSS.escape(key)}"]`);
const cc = clampToCol(lay.colBounds, key, r[key].x, CW);
const next = { ...r, [key]: { ...r[key], x: cc.x, w: cc.w, auto: true, h: measuredHeight(el?.offsetHeight ?? 0) } };
setRel(next);
saveLayout(next, lay.colBounds, byDate);
}, [byDate]);
// полоса дат и нижняя полоса прокрутки повторяют горизонтальную прокрутку доски (строки 705–714)
const syncScroll = useCallback(() => {
const w = wrapRef.current;
if (!w) return;
setScroll({ left: w.scrollLeft, wide: w.scrollWidth > w.clientWidth + 1, wrapW: w.offsetWidth, scrollW: w.scrollWidth });
const h = hscrollRef.current;
if (h && Math.abs(h.scrollLeft - w.scrollLeft) > 1) h.scrollLeft = w.scrollLeft;
}, []);
useLayoutEffect(() => {
syncScroll();
const w = wrapRef.current;
if (!w || typeof ResizeObserver === "undefined") return;
const ro = new ResizeObserver(syncScroll);
ro.observe(w);
return () => ro.disconnect();
}, [syncScroll, placed]);
const empty = !vis.nodes.length;
const multiSprint = layout?.multiSprint ?? false;
// слой измерения рисует метку спринта так же, как доска: она влияет на высоту карточки (строка 661)
const measureMulti = new Set(vis.nodes.filter((x) => !x.stub).map((x) => x.sprint)).size > 1;
const boardTop = () => boardRef.current?.getBoundingClientRect().top ?? 0;
const counts = Object.fromEntries(vis.lanes.map((l) => [l.name, l.keys.length]));
const byKey = Object.fromEntries(vis.nodes.map((n) => [n.key, n]));
return (
<>
{byDate && !empty && layout?.dateCols && placed && (
<DatesBar cols={layout.dateCols} width={placed.width} scrollLeft={scroll.left} wrapW={scroll.wrapW} inRef={datesInRef} onColWidth={onColWidth} />
)}
<div className={"stage" + (byDate && !empty ? " bydate" : "")}>
<div className="wrap" ref={wrapRef} onScroll={syncScroll}>
<div className={"board" + (byDate ? " bydate" : "")} ref={boardRef}
style={placed && !empty ? { width: placed.width, height: placed.height } : { width: "100%", height: "auto" }}>
{empty && <div className="empty">{emptyText}</div>}
{placed && !empty && (
<>
{layout?.dateCols?.map((c) => (
<div key={c.date} className={"dcol" + (c.cls ? " " + c.cls : "")} style={{ left: c.x0, width: c.w, height: placed.height }} />
))}
{placed.lanes.map((g, i) => (
<Lane key={g.name} g={g} index={i} last={placed.lanes.length - 1} count={counts[g.name] ?? 0} lanes={placed.lanes}
boardTop={boardTop} onToggle={onToggleLane} onMove={onMoveLane}
targetClass={dragTarget && dragTarget.to === i ? (i < dragTarget.from ? "target" : i > dragTarget.from ? "target-after" : "") : ""}
onDragTarget={(to) => setDragTarget(to === null ? null : { from: i, to })} />
))}
{Object.entries(placed.pos).filter(([k]) => byKey[k]).map(([k, box]) => (
<Card key={k} node={byKey[k]} box={box} rel={rel[k]} multiSprint={multiSprint} sprintSel={sprintSel}
onPointerDown={onCardDown} onAutoSize={onAutoSize} />
))}
<Edges links={vis.links} placed={placed} />
<Guides x={guides?.x ?? null} y={guides?.y ?? null} width={placed.width} height={placed.height} />
</>
)}
</div>
</div>
{children}
</div>
<div className="hscroll" ref={hscrollRef} hidden={!scroll.wide}
style={{ width: scroll.wrapW }}
onScroll={(e) => { const w = wrapRef.current; if (w && Math.abs(e.currentTarget.scrollLeft - w.scrollLeft) > 1) w.scrollLeft = e.currentTarget.scrollLeft; }}>
<div style={{ width: scroll.scrollW, height: 1 }} />
</div>
<div className="measure" ref={measureRef} aria-hidden="true">
{vis.nodes.map((n) => <Card key={n.key} node={n} multiSprint={measureMulti} sprintSel={sprintSel} />)}
</div>
</>
);
}

View File

@ -0,0 +1,50 @@
import { memo, type PointerEvent } from "react";
import { Link } from "react-router";
import { CW, JIRA_URL, KIND, PRIO } from "../constants.ts";
import { lineClamp, type Rect, type RelBox } from "../layout/layout.ts";
import { fmtDate, shortName, unassigned } from "../model/util.ts";
import type { VisNode } from "../types.ts";
export interface CardProps {
node: VisNode;
box?: Rect; // нет — карточка слоя измерения (ширина CW, высота по содержимому)
rel?: RelBox;
multiSprint: boolean;
sprintSel: Set<string>;
onPointerDown?: (ev: PointerEvent<HTMLDivElement>, key: string) => void;
onAutoSize?: (key: string) => void;
}
export const Card = memo(function Card({ node: n, box, rel, multiSprint, sprintSel, onPointerDown, onAutoSize }: CardProps) {
const cls = "card" + (n.dim || n.stub ? " out" : "") + (n.crit ? " crit" : "") + (n.focus ? " focus" : "");
const style = box ? { left: box.x, top: box.y, width: box.w, height: box.h } : { width: CW };
const showTag = !n.stub && (multiSprint || n.dim); // метка спринта — если спринтов на доске больше одного или это сосед
return (
<div className={cls} title={n.title} style={style} data-key={n.key}
onPointerDown={onPointerDown ? (e) => onPointerDown(e, n.key) : undefined}>
<div className="head">
<a className="key" href={`${JIRA_URL}/browse/${n.key}`} target="_blank" rel="noopener">{n.key}</a>
{!n.stub && <Link className="open-card" to={`/issues/${n.key}`} title="Открыть карточку задачи"
aria-label={`Открыть карточку ${n.key}`}>ⓘ</Link>}
{showTag && (n.sprint
? <span className={"sprint-tag" + (sprintSel.has(n.sprint) ? " sel" : "")} title={n.sprint}>{n.sprint.replace(/^AWPCA_/, "")}</span>
: <span className="sprint-tag none">без спринта</span>)}
</div>
<div className="title" style={{ WebkitLineClamp: box && rel ? lineClamp(rel) : "unset" }}>{n.title}</div>
<div className="meta">
<span className="kind"><i style={{ background: `var(--k-${KIND[n.kind] || "org"})` }} />{n.kind}</span>
<span className="prio" style={{ color: `var(--p-${PRIO[n.prio] || "mid"})` }}>{n.prio}</span>
</div>
{(n.status || n.due) && (
<div className="state">
{n.status && <span className="status" title={n.status}>{n.status}</span>}
{n.due && <span className="due">до {fmtDate(n.due)}</span>}
</div>
)}
{!n.stub && <div className={"who" + (unassigned(n.who) ? " none" : "")} title={n.who}>{shortName(n.who)}</div>}
<div className="rsz" title="Потянуть — изменить размер; двойной щелчок — размер по содержимому"
onDoubleClick={onAutoSize ? () => onAutoSize(n.key) : undefined} />
<div className="grip t" title="Тянуть за рамку" /><div className="grip b" /><div className="grip l" /><div className="grip r" />
</div>
);
});

View File

@ -0,0 +1,44 @@
import { Fragment, useRef, type PointerEvent, type RefObject } from "react";
import { COLW, GRID } from "../constants.ts";
import { dateText, dateTitle, type DateCol } from "../layout/layout.ts";
// липкая полоса подписей дат над доской: сдвиг — как у горизонтальной прокрутки доски; ручка границы меняет ширину колонки
export function DatesBar({ cols, width, scrollLeft, wrapW, inRef, onColWidth }: {
cols: DateCol[]; width: number; scrollLeft: number; wrapW: number; inRef: RefObject<HTMLDivElement | null>;
onColWidth(date: string, w: number | null): void;
}) {
const raf = useRef(0);
const down = (ev: PointerEvent<HTMLDivElement>, c: DateCol) => {
ev.preventDefault();
const handle = ev.currentTarget, x0 = ev.clientX;
handle.classList.add("on"); inRef.current?.classList.add("resizing");
let last = c.w;
const apply = (w: number) => onColWidth(c.date, w <= COLW ? null : Math.round(w / GRID) * GRID);
const move = (e: globalThis.PointerEvent) => {
last = Math.max(COLW, c.w + e.clientX - x0);
if (!raf.current) raf.current = requestAnimationFrame(() => { raf.current = 0; apply(last); });
};
const stop = () => {
window.removeEventListener("pointermove", move); window.removeEventListener("pointerup", stop); window.removeEventListener("pointercancel", stop);
handle.classList.remove("on"); inRef.current?.classList.remove("resizing");
apply(last);
};
window.addEventListener("pointermove", move); window.addEventListener("pointerup", stop); window.addEventListener("pointercancel", stop);
};
const cls = (c: DateCol) => (c.cls ? " " + c.cls : "");
return (
<div className="dates-bar">
<div className="dates-in" ref={inRef} style={{ width: wrapW }}>
<div className="dates-track" style={{ width, transform: `translateX(${-scrollLeft}px)` }}>
{cols.map((c) => (
<Fragment key={c.date}>
<span className={"dlabel" + cls(c)} style={{ left: c.x0 + 8 }} title={dateTitle(c)}>{dateText(c)}<span className="n">{c.count}</span></span>
<div className="dsplit" style={{ left: c.x0 + c.w }} title="Тянуть — ширина колонки; двойной щелчок — автоматическая"
onPointerDown={(e) => down(e, c)} onDoubleClick={() => onColWidth(c.date, null)} />
</Fragment>
))}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,23 @@
import { useEffect, useRef, useState, type ReactNode } from "react";
// выпадающий список: кнопка открывает, щелчок вне или Escape закрывает (строки 897–904)
export function Dropdown({ button, className = "", children, onOpen }: {
button: (p: { open: boolean; toggle(): void }) => ReactNode; className?: string;
children: (close: () => void) => ReactNode; onOpen?: () => void;
}) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const away = (e: PointerEvent) => { if (!ref.current?.contains(e.target as Node)) setOpen(false); };
document.addEventListener("pointerdown", away);
return () => document.removeEventListener("pointerdown", away);
}, [open]);
const toggle = () => setOpen((o) => { if (!o) onOpen?.(); return !o; });
return (
<div className={"dd " + className} ref={ref} onKeyDown={(e) => { if (e.key === "Escape") setOpen(false); }}>
{button({ open, toggle })}
{open && <div className={"dd-menu" + (className.includes("more") ? " menu" : "")}>{children(() => setOpen(false))}</div>}
</div>
);
}

View File

@ -0,0 +1,38 @@
import type { Placed } from "../layout/layout.ts";
import { edgePath } from "../layout/edges.ts";
import { RELATED } from "../constants.ts";
import type { VisLink } from "../types.ts";
export function Edges({ links, placed }: { links: VisLink[]; placed: Placed }) {
return (
<svg className="edges">
<defs>
<marker id="arr" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M0 0 L10 5 L0 10 z" fill="var(--edge-strong)" />
</marker>
<marker id="arrHand" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M0 0 L10 5 L0 10 z" fill="var(--guide)" />
</marker>
</defs>
{links.map((l) => {
const a = placed.pos[l.src], b = placed.pos[l.dst];
if (!a || !b) return null;
const related = l.type === RELATED;
return <path key={`${l.src}>${l.dst}${l.type}`} d={edgePath(a, b)} fill="none"
stroke={related ? "var(--edge)" : l.hand ? "var(--guide)" : "var(--edge-strong)"}
strokeWidth={l.crit ? "4" : related ? "1.4" : "1.8"}
strokeDasharray={related ? "5 4" : undefined}
markerEnd={related ? undefined : l.hand ? "url(#arrHand)" : "url(#arr)"} />;
})}
</svg>
);
}
export function Guides({ x, y, width, height }: { x: number | null; y: number | null; width: number; height: number }) {
return (
<svg className="guides">
{x !== null && <line x1={x} y1={0} x2={x} y2={height} />}
{y !== null && <line x1={0} y1={y} x2={width} y2={y} />}
</svg>
);
}

View File

@ -0,0 +1,20 @@
import { useState } from "react";
export function ExportBox({ text, onClose }: { text: string; onClose(): void }) {
const [copied, setCopied] = useState(false);
const copy = async (ta: HTMLTextAreaElement | null) => {
try { await navigator.clipboard.writeText(text); setCopied(true); setTimeout(() => setCopied(false), 1500); }
catch { ta?.focus(); ta?.select(); }
};
let ta: HTMLTextAreaElement | null = null;
return (
<div className="export">
<div className="row">
<span className="hint">JSON доски: все задачи, связи, раскладка, дорожки и фильтры. Загружается обратно ссылкой «файл…». Если файл не скачался, скопируйте текст.</span>
<button className="btn" type="button" onClick={() => void copy(ta)}>{copied ? "Скопировано" : "Копировать"}</button>
<button className="btn" type="button" onClick={onClose}>Закрыть</button>
</div>
<textarea readOnly spellCheck={false} value={text} ref={(el) => { ta = el; }} />
</div>
);
}

View File

@ -0,0 +1,27 @@
import { sprintBox, sprintItems, whoBox, whoItems } from "../analysis/facets.ts";
import type { BoardPrefsApi } from "../useBoardPrefs.ts";
import type { BoardModel } from "../types.ts";
import { MultiSelect } from "./MultiSelect.tsx";
const TOGGLES = [
{ name: "neighbors", label: "+ соседи по связям", title: "Добавить к отобранным задачам их соседей по связям из любого спринта, любого исполнителя и вида работы. Соседи рисуются пунктиром и считаются отдельно" },
{ name: "onlyHand", label: "⇄ передачи", title: "Оставить только стрелки, где задачу ждёт другой человек. Карточки не убирает — для этого «скрыть без связей»" },
{ name: "onlyLinked", label: "скрыть без связей", title: "Убрать с доски карточки, у которых не осталось ни одной видимой стрелки" },
{ name: "crit", label: "⚑ критический путь", title: "Самая длинная цепочка блокировок по плановым датам окончания" },
{ name: "byDate", label: "▥ по датам", title: "Разложить карточки по колонкам плановых дат окончания. У этого режима своя расстановка: карточку можно двигать внутри её колонки; обычная расстановка не меняется и вернётся, когда режим выключен" },
] as const;
export function FilterBar({ model, prefs }: { model: BoardModel; prefs: BoardPrefsApi }) {
const f = prefs.filters, sb = sprintBox(model), wb = whoBox(model, f);
return (
<div className="bar">
<span>{sb.kind === "text" ? <span className="file-name">{sb.text}</span>
: sb.kind === "multi" && <MultiSelect items={sprintItems(model, f).items} selected={f.sprints} allLabel="Все спринты" onChange={prefs.setSprints} />}</span>
<span>{wb.kind === "text" ? <span className="file-name">{wb.text}</span>
: wb.kind === "multi" && <MultiSelect items={whoItems(model, f).items} selected={f.who} allLabel="Все исполнители" onChange={prefs.setWho} />}</span>
{TOGGLES.map((t) => (
<button key={t.name} className="btn" type="button" aria-pressed={f[t.name]} title={t.title} onClick={() => prefs.toggle(t.name)}>{t.label}</button>
))}
</div>
);
}

View File

@ -0,0 +1,52 @@
import { useRef, useState, type PointerEvent } from "react";
import type { LaneGeom } from "../layout/layout.ts";
import { plural } from "../model/util.ts";
interface LaneProps {
g: LaneGeom; index: number; last: number; count: number;
lanes: LaneGeom[]; // все дорожки — для выбора места при перетаскивании
boardTop: () => number; // верх доски в координатах окна
targetClass: string; // "target" / "target-after" — подсветка целевой границы (строка 729)
onToggle(name: string): void;
onMove(name: string, to: number): void;
onDragTarget(to: number | null): void; // цель перетаскивания этой дорожки; null — перетаскивание закончено
}
export function Lane({ g, index, last, count, lanes, boardTop, targetClass, onToggle, onMove, onDragTarget }: LaneProps) {
const [dragging, setDragging] = useState(false);
const nameRef = useRef<HTMLSpanElement>(null);
const where = (clientY: number) => {
const y = clientY - boardTop();
const i = lanes.findIndex((x) => y < x.y + x.h / 2);
return i < 0 ? lanes.length - 1 : i;
};
const down = (ev: PointerEvent<HTMLSpanElement>) => {
ev.preventDefault();
const el = nameRef.current!;
el.setPointerCapture(ev.pointerId);
let target = index;
setDragging(true);
const move = (e: globalThis.PointerEvent) => { target = where(e.clientY); onDragTarget(target); };
const stop = () => {
el.removeEventListener("pointermove", move); el.removeEventListener("pointerup", stop); el.removeEventListener("pointercancel", stop);
setDragging(false);
onDragTarget(null);
if (target !== index) onMove(g.name, target);
};
el.addEventListener("pointermove", move); el.addEventListener("pointerup", stop); el.addEventListener("pointercancel", stop);
};
const cls = "lane" + (g.folded ? " folded" : "") + (dragging ? " dragging" : "") + (targetClass ? " " + targetClass : "");
return (
<div className={cls} style={{ top: g.y, height: g.h }}>
<div className="lane-head">
<button type="button" className="fold" title="Свернуть или развернуть дорожку" onClick={() => onToggle(g.name)} />
<span ref={nameRef} className="lane-name" title="Перетащить, чтобы поменять дорожки местами" onPointerDown={down}>{g.name}</span>
<span className="lane-count">{plural(count, "задача", "задачи", "задач")}</span>
<span className="lane-tools">
<button type="button" title="Выше" disabled={index === 0} onClick={() => onMove(g.name, index - 1)}>▲</button>
<button type="button" title="Ниже" disabled={index === last} onClick={() => onMove(g.name, index + 1)}>▼</button>
</span>
</div>
</div>
);
}

View File

@ -0,0 +1,25 @@
import type { ReactNode } from "react";
import type { BoardPrefsApi } from "../useBoardPrefs.ts";
const KINDS = [["decision", "решение"], ["analytics", "аналитика / актуализация"], ["dev", "разработка"], ["ui", "UI"],
["test", "тестирование"], ["org", "организационная"]] as const;
const EDGES = [["block", "блокирует начало → заблокирована", "ln"], ["rel", "связанные", "ln dash"]] as const;
export function Legend({ prefs }: { prefs: BoardPrefsApi }) {
const { hideKind, hideEdge } = prefs.filters;
const btn = (what: "kind" | "edge", value: string, off: boolean, body: ReactNode) => (
<button key={what + value} type="button" className={"lg" + (off ? " off" : "")} aria-pressed={!off}
title={off ? "Показать снова" : "Скрыть на доске"} onClick={() => prefs.toggleHidden(what, value)}>{body}</button>
);
return (
<div className="legend">
{KINDS.map(([k, label]) => btn("kind", k, hideKind.has(k), <><span className="sw" style={{ background: `var(--k-${k})` }} />{label}</>))}
<span className="sep" />
{EDGES.map(([k, label, ln]) => btn("edge", k, hideEdge.has(k), <><span className={ln} />{label}</>))}
<span><span className="ln hand" />передача другому исполнителю</span>
<span><span className="ln crit" />критический путь</span>
<span><span className="out-sw" />сосед по связям, сам под фильтры не попадает</span>
<span className="hint">щелчок по виду работы или типу связи скрывает их</span>
</div>
);
}

View File

@ -0,0 +1,13 @@
import type { VisLink } from "../types.ts";
export function LinkTable({ links }: { links: VisLink[] }) {
return (
<details open>
<summary>Таблица связей</summary>
<div className="tbl"><table>
<thead><tr><th>Блокирует</th><th>Тип</th><th>Заблокирована</th></tr></thead>
<tbody>{links.map((l) => <tr key={`${l.src}>${l.dst}${l.type}`}><td className="k">{l.src}</td><td>{l.type}</td><td className="k">{l.dst}</td></tr>)}</tbody>
</table></div>
</details>
);
}

View File

@ -0,0 +1,40 @@
import { useState } from "react";
import type { FacetItem } from "../analysis/facets.ts";
import { Dropdown } from "./Dropdown.tsx";
// мультиселект с поиском: пустой выбор = без фильтра (строки 913–938)
export function MultiSelect({ items, selected, allLabel, onChange }: {
items: FacetItem[]; selected: Set<string>; allLabel: string; onChange(next: Set<string>): void;
}) {
const [q, setQ] = useState("");
const sel = items.filter((i) => selected.has(i.value));
const label = !sel.length ? allLabel : sel.length === 1 ? sel[0].label : `${sel[0].label} +${sel.length - 1}`;
const shown = items.filter((i) => !q || i.label.toLowerCase().includes(q.toLowerCase()) || i.value.toLowerCase().includes(q.toLowerCase()));
const flip = (v: string, on: boolean) => { const n = new Set(selected); if (on) n.add(v); else n.delete(v); onChange(n); };
return (
<Dropdown onOpen={() => setQ("")} button={({ open, toggle }) => (
<button type="button" className="btn dd-btn" aria-haspopup="listbox" aria-expanded={open} title={sel.map((i) => i.label).join(", ")} onClick={toggle}>
<span className="lbl">{label}</span><span className="car">▼</span>
</button>
)}>
{() => (
<>
<input type="search" placeholder="Поиск…" aria-label="Поиск" autoFocus value={q} onChange={(e) => setQ(e.target.value)} />
<div className="dd-list" role="listbox">
{!shown.length && <div className="dd-empty">ничего не найдено</div>}
{shown.map((i) => (
<label key={i.value} className={"dd-item" + (i.cls ? " " + i.cls : "")} title={i.title || i.label}>
<input type="checkbox" checked={selected.has(i.value)} onChange={(e) => flip(i.value, e.target.checked)} />
<span>{i.label}</span><span className="n">{i.count}</span>
</label>
))}
</div>
<div className="dd-foot">
<button type="button" className="btn all" onClick={() => onChange(new Set(items.map((i) => i.value)))}>Выбрать все</button>
<button type="button" className="btn clear" onClick={() => onChange(new Set())}>Снять выбор</button>
</div>
</>
)}
</Dropdown>
);
}

View File

@ -0,0 +1,30 @@
import { JIRA_URL } from "../constants.ts";
import type { ProblemItem, Problems } from "../types.ts";
export const problemCount = (p: Problems) => p.dates.length + p.unassigned.length + p.late.length;
const K = ({ k }: { k: string }) => <a className="k" href={`${JIRA_URL}/browse/${k}`} target="_blank" rel="noopener">{k}</a>;
function Section({ title, list }: { title: string; list: ProblemItem[] }) {
if (!list.length) return null;
return (
<>
<h4>{title} ({list.length})</h4>
<ul>{list.map((x, i) => <li key={i}><K k={x.a.key} /> → <K k={x.b.key} /> <span className="why">— {x.text}</span></li>)}</ul>
</>
);
}
export function ProblemsDrawer({ problems, onClose }: { problems: Problems; onClose(): void }) {
return (
<aside className="drawer">
<div className="drawer-head"><b>Проблемы на доске</b><button type="button" className="btn sm" onClick={onClose}>Закрыть</button></div>
<div>
{!problemCount(problems) && <div className="ok">По датам, исполнителям и статусам блокеров на видимой части доски проблем нет.</div>}
<Section title="Срок блокера не раньше срока зависимой задачи" list={problems.dates} />
<Section title="Блокер без исполнителя" list={problems.unassigned} />
<Section title="Блокер не начат, а срок зависимой задачи близко" list={problems.late} />
<Section title="Передачи между исполнителями (точки ожидания)" list={problems.hand} />
</div>
</aside>
);
}

View File

@ -0,0 +1,84 @@
import { useEffect, useState } from "react";
import { applyTheme, readTheme, saveTheme, THEME_NAMES, THEMES, type Theme } from "../../shell/theme.ts";
import { statusText, type BoardStatus } from "../status.ts";
import type { BoardModel, Visible } from "../types.ts";
import { NOT_IN_HUB } from "../constants.ts";
import { Dropdown } from "./Dropdown.tsx";
interface ToolbarProps {
label: string; title: string; status: BoardStatus | null; refreshing: boolean; byDate: boolean; snap: boolean;
model: BoardModel; vis: Visible; problemCount: number; problemsOpen: boolean; tableOpen: boolean;
onReload(): void; onRefresh(): void; onFile(name: string, text: string): void; onProblems(): void;
onExport(): void; onTable(): void; onSnap(): void; onReset(): void;
}
export function Toolbar(p: ToolbarProps) {
const [theme, setTheme] = useState<Theme>(readTheme);
const [confirm, setConfirm] = useState(false);
useEffect(() => { if (!confirm) return; const t = setTimeout(() => setConfirm(false), 8000); return () => clearTimeout(t); }, [confirm]);
const st = p.status ? statusText(p.status) : null;
const m = p.model, v = p.vis, r = v.removed;
const coreShown = v.nodes.filter((n) => !n.dim).length, ctxShown = v.nodes.length - coreShown;
const nextTheme = () => { const t = THEMES[(THEMES.indexOf(theme) + 1) % THEMES.length]; setTheme(t); saveTheme(t); applyTheme(t); };
return (
<div className="top">
<h1>Доска связей</h1>
<span className="src">
<span className="file-name" title={p.title}>{p.label}</span>
<button type="button" className="link" title="Загрузить снимок зеркала хаба заново, без обращения к Jira" onClick={p.onReload}>Перечитать</button>
<button type="button" className="link" disabled={p.refreshing} onClick={p.onRefresh}
title="Поставить в хаб пакет «обновление по базовой JQL» и дождаться его: агент перечитает задачи из Jira">Обновить из Jira</button>
<label className="link" title="CSV из фильтра Jira или JSON, выгруженный этой доской. JSON при работе с хабом переносит только расстановку карточек, дорожки и фильтры; CSV показывает снимок файла до «Перечитать»">
файл…
<input type="file" accept=".csv,.json,text/csv,application/json" hidden onChange={async (e) => {
const f = e.target.files?.[0];
e.target.value = "";
if (f) p.onFile(f.name, await f.text());
}} />
</label>
</span>
{st && <span className={"stats" + (st.warn ? " warn" : "")} role="status">{st.text}</span>}
<span className="stats">
<b>{coreShown}</b> задач из {m.nodes.filter((n) => !n.stub).length}
{ctxShown > 0 && <> · <b>+{ctxShown}</b> соседи по связям</>}
{" · "}<b>{v.links.length}</b> связей из {m.links.length}
{r.loose > 0 && <> · скрыто без связей: {r.loose}</>}
</span>
<span className="spacer" />
<button className="btn" type="button" aria-pressed={p.problemsOpen} onClick={p.onProblems}
title="Сроки блокеров, задачи без исполнителя, передачи между людьми">
Проблемы <span className={"n" + (p.problemCount ? "" : " zero")}>{p.problemCount}</span>
</button>
<Dropdown className="more" button={({ open, toggle }) => (
<button type="button" className="btn more-btn" aria-haspopup="menu" aria-expanded={open} title="Ещё" onClick={toggle}>⋯</button>
)}>
{(close) => (
<>
<button type="button" onClick={() => { close(); p.onExport(); }}>Экспорт JSON</button>
<button type="button" aria-pressed={p.tableOpen} onClick={p.onTable}>Таблица связей</button>
<button type="button" aria-pressed={p.snap} onClick={p.onSnap}>Привязка к сетке</button>
<button type="button" onClick={nextTheme}>Тема: {THEME_NAMES[theme]}</button>
<hr />
{!confirm ? <button type="button" className="danger" onClick={() => setConfirm(true)}>Сбросить раскладку</button> : (
<div className="confirm">
<span>Вернуть автоматическую раскладку {p.byDate ? "режима «по датам»" : "(обычную; раскладка «по датам» останется)"}, порядок и раскрытие дорожек?</span>
<div className="row">
<button type="button" className="btn yes" onClick={() => { p.onReset(); setConfirm(false); close(); }}>Да, сбросить</button>
<button type="button" className="btn no" onClick={() => setConfirm(false)}>Нет</button>
</div>
</div>
)}
</>
)}
</Dropdown>
</div>
);
}
// предупреждения о данных под шапкой (строки 878–880)
export function Notice({ model }: { model: BoardModel }) {
const notes = [...model.problems];
if (model.missing) notes.push(`упомянуты в связях, но в ${model.stubLane === NOT_IN_HUB ? "зеркале хаба" : "файле"} отсутствуют: ${model.missing}`);
if (!notes.length) return null;
return <div className="notice">{notes.map((n) => <span key={n}>{n}</span>)}</div>;
}

View File

@ -0,0 +1,14 @@
export const JIRA_URL = "https://sdlc.go.rshbank.ru/jira";
// ключ эпика → название дорожки; строки типа «Фича» в файле дополняют список
export const EPICS: Record<string, string> = {"AURORA-1350": "Архитектура и инфраструктура", "AURORA-1351": "Справочники ГК / ГСК / ГСЗ / ЮЛ", "AURORA-1352": "Консолидация: карточка, версии, статусы", "AURORA-1353": "Загрузка документов и распознавание", "AURORA-1354": "Сервисы распознавания и парсинга", "AURORA-1355": "Логические проверки отчётности", "AURORA-1356": "Сводная ОСВ", "AURORA-1357": "Сопоставление ВГО и Шахматка", "AURORA-1358": "Расхождения и корректировки", "AURORA-1359": "Гудвилл", "AURORA-1360": "Формирование КФО", "AURORA-1361": "Экспорт", "AURORA-1362": "Журнал аудита", "AURORA-1363": "Единое хранилище (ЕХФД)", "AURORA-1364": "Аналитика", "AURORA-1529": "Роли, доступ и аутентификация (SSO)", "AURORA-1530": "Приёмка и передача системы (тестирование КА, ПСИ)"};
export const KIND: Record<string, string> = {"решение": "decision", "аналитика": "analytics", "актуализация": "analytics", "разработка": "dev", "UI": "ui", "тестирование": "test", "организационная": "org"}; // вид работы → цвет и фильтр легенды
export const PRIO: Record<string, string> = {"Критичный": "crit", "Наивысший": "crit", "Высокий": "high", "Средний": "mid", "Низкий": "low", "Самый низкий": "low"};
export const RELATED = "связанные"; // единственный ненаправленный тип связи
export const NO_EPIC = "Без эпика", NOT_IN_FILE = "Нет в файле", NOT_IN_HUB = "Нет в зеркале"; // служебные дорожки, всегда внизу
export const STUB_TEXT: Record<string, string> = { [NOT_IN_FILE]: "нет в файле", [NOT_IN_HUB]: "нет в зеркале" }; // подпись заглушки на служебной дорожке
export const KEY_RE = /^[A-Z][A-Z0-9]+-\d+$/;
// геометрия доски, px: карточка, колонка слоя, отступы, свёрнутая дорожка, сетка и порог примагничивания
export const CW = 240, COLW = 296, LEFT = 24, TOP = 40, MAXROWS = 4, FOLD_H = 36, GRID = 8, SNAP = 6, MINW = 160, MINH = 80, TITLE_LH = 19;
// в режиме «по датам» карточка не выходит за свою колонку даты: x и ширина зажимаются по границам колонки
export const CPAD = 6;
export const EDGE_R = 10;

View File

@ -0,0 +1,15 @@
import { describe, expect, it } from "vitest";
import golden from "./__fixtures__/hub-basic.expected.json";
import settings from "./__fixtures__/settings.json";
import { dictsFromSettings } from "./dicts.ts";
describe("dictsFromSettings", () => {
it("обращает словари настроек и совпадает со словарями старой страницы", () => {
const { dicts, missing } = dictsFromSettings(settings);
expect(dicts).toEqual(golden.dicts);
expect(missing).toEqual([]);
});
it("называет пустые и отсутствующие словари", () => {
expect(dictsFromSettings({ kind_labels: {} }).missing).toEqual(["kind_labels", "link_types"]);
});
});

13
web/src/board/dicts.ts Normal file
View File

@ -0,0 +1,13 @@
import type { HubSettings } from "../api/types.ts";
import type { Dicts } from "./types.ts";
// словари доски из настроек хаба: kind_labels (вид → метка) и link_types (логический тип → имя Jira) обращаются
// в «метка → вид» и «имя Jira → логический тип»; пустой словарь — в missing (доска сообщит, чего не хватает)
const invert = (o: Record<string, string> | undefined) => Object.fromEntries(Object.entries(o ?? {}).map(([k, v]) => [v, k]));
export function dictsFromSettings(s: HubSettings): { dicts: Dicts; missing: string[] } {
const dicts = { kindLabels: invert(s.kind_labels), linkTypes: invert(s.link_types) };
const missing = [["kind_labels", dicts.kindLabels], ["link_types", dicts.linkTypes]]
.filter(([, d]) => Object.keys(d as object).length === 0).map(([n]) => n as string);
return { dicts, missing };
}

View File

@ -0,0 +1,35 @@
// полный снимок доски: все задачи и связи, обе раскладки относительно дорожек (layout — обычная, layoutByDate — «по датам»; h только если задана вручную),
// порядок и свёртка дорожек, фильтры. Текущую раскладку компонент сохраняет в хранилище (saveLayout) перед вызовом
import { JIRA_URL } from "./constants.ts";
import { colWidths, foldedSet, laneOrder, savedLayout } from "./storage.ts";
import type { BoardModel, Filters, Visible } from "./types.ts";
type Saved = Record<string, { x: number; ry: number; w?: number; h?: number }>;
export function exportJson(model: BoardModel, vis: Visible, f: Filters, now: string): unknown {
const savedPos = (savedLayout(false) || {}) as Saved, savedDate = (savedLayout(true) || {}) as Saved;
const shown = new Set(vis.nodes.map((n) => n.key));
const fromSaved = (saved: Saved, k: string) => {
const r = saved[k]; if (!r) return null;
const o: { x: number; ry: number; w?: number; h?: number } = { x: r.x, ry: r.ry };
if (typeof r.w === "number") o.w = r.w; // запись без w допустима (как в эталоне)
if (typeof r.h === "number") o.h = r.h;
return o;
};
const nodes = model.nodes.filter((n) => !n.stub).map((n) => ({
key: n.key, title: n.title, status: n.status, priority: n.prio, kind: n.kind, due: n.due || null, assignee: n.who || null, sprint: n.sprint || null, epic: n.epic,
url: `${JIRA_URL}/browse/${n.key}`, visible: shown.has(n.key), layout: fromSaved(savedPos, n.key), layoutByDate: fromSaved(savedDate, n.key),
}));
const order = laneOrder();
return {
format: "arm-ka-links/1", exportedAt: now, source: model.fileName,
filters: { sprints: [...f.sprints], assignees: [...f.who], neighbors: f.neighbors, onlyLinked: f.onlyLinked, onlyHand: f.onlyHand, criticalPath: f.crit, byDate: f.byDate,
hiddenKinds: [...f.hideKind], hiddenEdges: [...f.hideEdge] },
laneOrder: order.length ? order : vis.lanes.map((l) => l.name), collapsedLanes: [...foldedSet()], dateColumns: colWidths(),
nodes, links: model.links.map((l) => ({ from: l.src, to: l.dst, type: l.type })),
};
}
// имя файла выгрузки: links-<имя исходного файла без .csv>-<дата>.json
export function exportFileName(model: BoardModel, now: string): string {
return `links-${(model.fileName || "board").replace(/[^\wА-Яа-яЁё.-]+/g, "_").replace(/\.csv$/i, "")}-${now.slice(0, 10)}.json`;
}

View File

@ -0,0 +1,22 @@
import { SNAP } from "../constants.ts";
import type { Rect } from "./layout.ts";
export interface Aligned { x: number; y: number; w: number; h: number; snappedX: boolean; snappedY: boolean; guideX: number | null; guideY: number | null }
// примагничивание к краям и центрам соседей в пределах SNAP px; при изменении размера — только правый и нижний край.
// Направляющие возвращаются координатами (рисует компонент)
export function alignEdges(pos: Record<string, Rect>, key: string, r: Rect, resizing: boolean): Aligned {
const xs: number[] = [], ys: number[] = [];
Object.entries(pos).forEach(([k, q]) => { if (k !== key) { xs.push(q.x, q.x + q.w / 2, q.x + q.w); ys.push(q.y, q.y + q.h / 2, q.y + q.h); } });
const mine = resizing ? { x: [r.x + r.w], y: [r.y + r.h] } : { x: [r.x, r.x + r.w / 2, r.x + r.w], y: [r.y, r.y + r.h / 2, r.y + r.h] };
const nearest = (vals: number[], targets: number[]) => {
let best: { d: number; t: number } | null = null;
vals.forEach((v) => targets.forEach((t) => { const d = t - v; if (Math.abs(d) <= SNAP && (!best || Math.abs(d) < Math.abs(best.d))) best = { d, t }; }));
return best as { d: number; t: number } | null;
};
const bx = nearest(mine.x, xs), by = nearest(mine.y, ys);
const out: Aligned = { x: r.x, y: r.y, w: r.w, h: r.h, snappedX: !!bx, snappedY: !!by, guideX: bx ? bx.t : null, guideY: by ? by.t : null };
if (bx) { if (resizing) out.w += bx.d; else out.x += bx.d; }
if (by) { if (resizing) out.h += by.d; else out.y += by.d; }
return out;
}

View File

@ -0,0 +1,39 @@
import { EDGE_R } from "../constants.ts";
import type { Rect } from "./layout.ts";
type Pt = { x: number; y: number };
// стрелка от блокера к зависимой — ломаная из горизонтальных и вертикальных отрезков со скруглёнными углами: выходит из карточки
// и входит в карточку перпендикулярно грани
export function orthoPath(pts: Pt[]): string {
let d = `M${pts[0].x} ${pts[0].y}`;
for (let i = 1; i < pts.length - 1; i++) {
const A = pts[i - 1], P = pts[i], B = pts[i + 1];
const l1 = Math.hypot(P.x - A.x, P.y - A.y), l2 = Math.hypot(B.x - P.x, B.y - P.y), r = Math.min(EDGE_R, l1 / 2, l2 / 2);
if (r < 1) { d += ` L${P.x} ${P.y}`; continue; }
const u1 = { x: (P.x - A.x) / l1, y: (P.y - A.y) / l1 }, u2 = { x: (B.x - P.x) / l2, y: (B.y - P.y) / l2 };
d += ` L${P.x - u1.x * r} ${P.y - u1.y * r} Q${P.x} ${P.y} ${P.x + u2.x * r} ${P.y + u2.y * r}`;
}
const E = pts[pts.length - 1];
return d + ` L${E.x} ${E.y}`;
}
// карточки разнесены по горизонтали — через середину промежутка; иначе по вертикали через середину промежутка;
// перекрываются и по вертикали — обход справа от обеих
export function edgePath(a: Rect, b: Rect): string {
if (b.x >= a.x + a.w + 20) {
const x1 = a.x + a.w, y1 = a.y + a.h / 2, x2 = b.x, y2 = b.y + b.h / 2, xm = Math.round((x1 + x2) / 2);
return orthoPath(y1 === y2 ? [{ x: x1, y: y1 }, { x: x2, y: y2 }] : [{ x: x1, y: y1 }, { x: xm, y: y1 }, { x: xm, y: y2 }, { x: x2, y: y2 }]);
}
if (a.x >= b.x + b.w + 20) {
const x1 = a.x, y1 = a.y + a.h / 2, x2 = b.x + b.w, y2 = b.y + b.h / 2, xm = Math.round((x1 + x2) / 2);
return orthoPath(y1 === y2 ? [{ x: x1, y: y1 }, { x: x2, y: y2 }] : [{ x: x1, y: y1 }, { x: xm, y: y1 }, { x: xm, y: y2 }, { x: x2, y: y2 }]);
}
const overlapY = b.y < a.y + a.h + 20 && b.y + b.h + 20 > a.y;
if (overlapY) {
const y1 = a.y + a.h / 2, y2 = b.y + b.h / 2, xr = Math.max(a.x + a.w, b.x + b.w) + 28;
return orthoPath([{ x: a.x + a.w, y: y1 }, { x: xr, y: y1 }, { x: xr, y: y2 }, { x: b.x + b.w, y: y2 }]);
}
const down = b.y >= a.y, x1 = a.x + a.w / 2, y1 = down ? a.y + a.h : a.y, x2 = b.x + b.w / 2, y2 = down ? b.y : b.y + b.h, ym = Math.round((y1 + y2) / 2);
return orthoPath(x1 === x2 ? [{ x: x1, y: y1 }, { x: x2, y: y2 }] : [{ x: x1, y: y1 }, { x: x1, y: ym }, { x: x2, y: ym }, { x: x2, y: y2 }]);
}

View File

@ -0,0 +1,74 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import type { BoardSnapshot } from "../../api/types.ts";
import { canon } from "../../test/canon.ts";
import { computeVisible } from "../analysis/visible.ts";
import { filtersOf } from "../../test/boardScenarios.ts";
import { dictsFromSettings } from "../dicts.ts";
import settings from "../__fixtures__/settings.json";
import scenarios from "../__fixtures__/scenarios.json";
import { fromHub } from "../model/fromHub.ts";
import { alignEdges } from "./align.ts";
import { edgePath, orthoPath } from "./edges.ts";
import { computeLayout, dateText, dateTitle, lineClamp, measuredHeight, place, type Rect } from "./layout.ts";
const fx = (name: string) => readFileSync(join(import.meta.dirname, "..", "__fixtures__", name), "utf8");
const golden = JSON.parse(fx("hub-basic.expected.json"));
const geo = JSON.parse(fx("geometry.cases.json"));
const geoG = JSON.parse(fx("geometry.expected.json"));
const { dicts } = dictsFromSettings(settings);
const model = fromHub(JSON.parse(fx("hub-basic.board.json")) as BoardSnapshot, dicts);
const TODAY = "2026-10-14";
const measure = () => ({ h: measuredHeight(117), other: 117 - 38 }); // как заглушка DOM генератора эталонов
type Sc = { name: string; filters: Record<string, unknown>; laneOrder?: string[]; folded?: string[];
saved?: unknown; savedDate?: unknown; colWidths?: Record<string, number> };
describe("раскладка = links.html@3931a88", () => {
for (const sc of scenarios as Sc[]) {
it(`computeLayout + place: ${sc.name}`, () => {
const f = filtersOf(sc);
const vis = computeVisible(model, f, sc.laneOrder ?? [], TODAY);
const g = golden.scenarios[sc.name].layout;
if (!vis.nodes.length) { expect(g.lanes).toEqual([]); return; }
const lay = computeLayout(vis, { byDate: f.byDate, colWidths: sc.colWidths ?? {},
saved: f.byDate ? sc.savedDate ?? null : sc.saved ?? null, today: TODAY, measure });
const placed = place(lay, lay.rel, new Set(sc.folded ?? []));
expect(canon(lay.rel)).toEqual(g.rel);
expect(canon(lay.laneOf)).toEqual(g.laneOf);
expect(canon(lay.colBounds)).toEqual(g.colBounds);
expect(lay.baseW).toBe(g.baseW);
expect(canon(placed.pos)).toEqual(g.pos);
expect(canon(placed.lanes)).toEqual(g.lanes);
expect({ width: placed.width + "px", height: placed.height + "px" }).toEqual(g.board);
expect(Object.fromEntries(Object.keys(lay.rel).map((k) => [k, placed.pos[k] ? lineClamp(lay.rel[k]) : "unset"])))
.toEqual(g.clamp);
const cls = (c: { cls: string }) => (c.cls ? " " + c.cls : "");
expect((lay.dateCols ?? []).map((c) => ({ className: "dcol" + cls(c), left: c.x0 + "px", width: c.w + "px" })))
.toEqual(g.dateCols);
// вне «по датам» эталон хранит подписи прошлого сценария (генератор не чистит полосу дат; она скрыта) — сравниваем только «по датам»
if (f.byDate) expect((lay.dateCols ?? []).map((c) => ({ className: "dlabel" + cls(c), left: c.x0 + 8 + "px", title: dateTitle(c),
innerHTML: `${dateText(c)}<span class="n">${c.count}</span>` }))).toEqual(g.dateLabels);
const edges = vis.links.filter((l) => placed.pos[l.src] && placed.pos[l.dst])
.map((l) => ({ src: l.src, dst: l.dst, type: l.type, hand: l.hand, crit: l.crit, d: edgePath(placed.pos[l.src], placed.pos[l.dst]) }));
expect(canon(edges)).toEqual(g.edges);
});
}
});
describe("геометрия = links.html@3931a88", () => {
it("edgePath и orthoPath", () => {
expect(geo.edge.map((c: { a: Rect; b: Rect }) => edgePath(c.a, c.b))).toEqual(geoG.edge);
expect(geo.ortho.map((p: { x: number; y: number }[]) => orthoPath(p))).toEqual(geoG.ortho);
});
it("alignEdges и направляющие", () => {
geo.align.forEach((c: { pos: Record<string, Rect>; key: string; r: Rect; resizing: boolean }, i: number) => {
const { guideX, guideY, ...out } = alignEdges(c.pos, c.key, c.r, c.resizing);
expect(out).toEqual(geoG.align[i].out);
const guides = [...(guideX === null ? [] : [{ x1: guideX, y1: 0, x2: guideX, y2: 0 }]),
...(guideY === null ? [] : [{ x1: 0, y1: guideY, x2: 0, y2: guideY }])];
expect(guides).toEqual(geoG.align[i].guides);
});
});
});

View File

@ -0,0 +1,128 @@
import { COLW, CPAD, CW, FOLD_H, GRID, LEFT, MAXROWS, MINH, MINW, TITLE_LH, TOP } from "../constants.ts";
import { fmtDate } from "../model/util.ts";
import type { SavedBox, Visible } from "../types.ts";
export interface Rect { x: number; y: number; w: number; h: number }
export interface RelBox { x: number; ry: number; w: number; h: number; auto: boolean; other?: number }
export interface DateCol { date: string; x0: number; w: number; count: number; cls: "" | "past" | "today" | "none" }
export interface Layout {
rel: Record<string, RelBox>; // раскладка карточек относительно дорожек (начальная; правки — у вызывающего)
laneOf: Record<string, number>; // индекс дорожки карточки
colBounds: Record<string, { x0: number; x1: number }>; // колонка даты карточки (только «по датам»)
baseW: number; // ширина доски по слоям
laneNames: string[];
dateCols: DateCol[] | null; // колонки дат («по датам»), иначе null
multiSprint: boolean; // на доске больше одного спринта — карточкам нужна метка спринта
}
export interface LaneGeom { name: string; y: number; h: number; minH: number; folded: boolean }
export interface Placed { lanes: LaneGeom[]; pos: Record<string, Rect>; width: number; height: number }
export const measuredHeight = (offsetHeight: number): number => Math.max(MINH, Math.ceil(offsetHeight / GRID) * GRID);
// в режиме «по датам» карточка не выходит за свою колонку даты: x и ширина зажимаются по границам колонки
export function clampToCol(colBounds: Layout["colBounds"], key: string, x: number, w: number): { x: number; w: number } {
const b = colBounds[key];
if (!b) return { x, w };
w = Math.max(MINW, Math.min(w, b.x1 - b.x0 - 2 * CPAD));
return { x: Math.max(b.x0 + CPAD, Math.min(x, b.x1 - CPAD - w)), w };
}
export function computeLayout(vis: Visible, o: {
byDate: boolean; colWidths: Record<string, number>; saved: unknown; today: string;
measure: (key: string) => { h: number; other: number };
}): Layout {
const rel: Record<string, RelBox> = {}, laneOf: Record<string, number> = {}, colBounds: Layout["colBounds"] = {};
const byKey = Object.fromEntries(vis.nodes.map((n) => [n.key, n]));
const preds: Record<string, string[]> = {};
vis.nodes.forEach((n) => { preds[n.key] = []; });
vis.links.forEach((l) => preds[l.dst].push(l.src));
const layer: Record<string, number> = {};
const L = (k: string, seen = new Set<string>()): number => {
if (layer[k] !== undefined) return layer[k];
if (seen.has(k)) return 0;
seen.add(k);
layer[k] = preds[k].length ? Math.max(...preds[k].map((p) => L(p, seen))) + 1 : 0;
return layer[k];
};
const dates = o.byDate ? [...new Set(vis.nodes.map((n) => n.due || ""))].sort((a, b) => (a || "~").localeCompare(b || "~")) : null;
if (dates) vis.nodes.forEach((n) => { layer[n.key] = dates.indexOf(n.due || ""); }); else vis.nodes.forEach((n) => L(n.key));
const maxLayer = Math.max(0, ...Object.values(layer)), layerCols: number[] = Array(maxLayer + 1).fill(1);
vis.lanes.forEach((lane) => {
const cnt: Record<number, number> = {};
lane.keys.forEach((k) => { cnt[layer[k]] = (cnt[layer[k]] || 0) + 1; });
Object.entries(cnt).forEach(([c, n]) => { layerCols[+c] = Math.max(layerCols[+c], Math.ceil(n / MAXROWS)); });
});
const cw = dates ? o.colWidths : {};
// ручная ширина не уже автоматической
const colW = (c: number) => (dates && typeof cw[dates[c]] === "number" ? Math.max(layerCols[c] * COLW, cw[dates[c]]) : layerCols[c] * COLW);
const layerX: number[] = [];
let x = LEFT;
for (let c = 0; c <= maxLayer; c++) { layerX[c] = x; x += colW(c); }
const baseW = x;
const stacks: string[][] = []; // подколонки: карточки сверху вниз
vis.lanes.forEach((lane, li) => {
const cols: Record<number, string[]> = {};
lane.keys.forEach((k) => { (cols[layer[k]] = cols[layer[k]] || []).push(k); laneOf[k] = li; });
Object.entries(cols).forEach(([c, ks]) => {
ks.sort((a, b) => ((byKey[a].due || "9") > (byKey[b].due || "9") ? 1 : -1));
const per = Math.ceil(ks.length / Math.ceil(ks.length / MAXROWS)), sub: string[][] = [];
ks.forEach((k, i) => {
const col = Math.floor(i / per);
rel[k] = { x: layerX[+c] + col * COLW, ry: TOP, w: CW, h: MINH, auto: true };
(sub[col] = sub[col] || []).push(k);
});
stacks.push(...sub);
});
});
let dateCols: DateCol[] | null = null;
if (dates) {
dateCols = dates.map((d, c) => ({
date: d, x0: layerX[c] - LEFT / 2, w: colW(c), count: vis.nodes.filter((n) => (n.due || "") === d).length,
cls: d ? (d < o.today ? "past" : d === o.today ? "today" : "") : "none",
}));
vis.nodes.forEach((n) => { const c = layer[n.key], x0 = layerX[c] - LEFT / 2; colBounds[n.key] = { x0, x1: x0 + colW(c) }; });
}
const multiSprint = new Set(vis.nodes.filter((n) => !n.stub).map((n) => n.sprint)).size > 1;
// высота по содержимому; other — высота всего, кроме заголовка (для обрезки заголовка при ручной высоте)
vis.nodes.forEach((n) => { const m = o.measure(n.key); rel[n.key].h = m.h; rel[n.key].other = m.other; });
stacks.forEach((list) => { let y = TOP; list.forEach((k) => { rel[k].ry = y; y += rel[k].h + 16; }); });
const saved = o.saved;
if (saved && typeof saved === "object") {
Object.entries(saved as Record<string, SavedBox>).forEach(([k, v]) => {
if (!rel[k] || !v || typeof v.x !== "number" || typeof v.ry !== "number") return;
const base = colBounds[k] ? colBounds[k].x0 : 0;
const manualH = typeof v.h === "number";
// дата могла смениться — карточка остаётся в своей колонке
const c = clampToCol(colBounds, k, Math.max(0, base + v.x), typeof v.w === "number" ? v.w : rel[k].w);
rel[k] = { x: c.x, ry: Math.max(TOP, v.ry), w: c.w, h: manualH ? (v.h as number) : rel[k].h, auto: !manualH, other: rel[k].other };
});
}
return { rel, laneOf, colBounds, baseW, laneNames: vis.lanes.map((l) => l.name), dateCols, multiSprint };
}
// координаты дорожек и карточек по rel: дорожка растёт под карточки, свёрнутая — только шапка, её карточки в pos не попадают
export function place(layout: Layout, rel: Record<string, RelBox>, folded: Set<string>): Placed {
let yy = 0, w = layout.baseW;
const pos: Record<string, Rect> = {};
const lanes: LaneGeom[] = layout.laneNames.map((name) => ({ name, y: 0, h: 0, minH: TOP + MINH + 24, folded: false }));
lanes.forEach((g, li) => {
g.folded = folded.has(g.name);
let h = g.folded ? FOLD_H : g.minH;
if (!g.folded) Object.entries(rel).forEach(([k, r]) => { if (layout.laneOf[k] === li) { h = Math.max(h, r.ry + r.h + 12); w = Math.max(w, r.x + r.w + LEFT); } });
g.y = yy; g.h = h; yy += h;
});
Object.entries(rel).forEach(([k, r]) => {
const g = lanes[layout.laneOf[k]];
if (g.folded) return;
pos[k] = { x: r.x, y: g.y + r.ry, w: r.w, h: r.h };
});
return { lanes, pos, width: w, height: yy };
}
// число строк заголовка при ручной высоте; «unset» — высота авто
export const lineClamp = (r: RelBox): string => (r.auto ? "unset" : String(Math.max(1, Math.floor((r.h - (r.other || 112)) / TITLE_LH))));
// подсказка подписи колонки даты
export const dateTitle = (c: DateCol): string =>
c.date ? (c.cls === "past" ? "срок прошёл" : c.cls === "today" ? "сегодня" : "плановая дата окончания") : "плановая дата не задана";
export const dateText = (c: DateCol): string => (c.date ? fmtDate(c.date) : "без срока");

View File

@ -0,0 +1,15 @@
// разбор CSV: BOM, кавычки с удвоением, CRLF/LF/CR; пустые строки отбрасываются
export function parseCsv(text: string): string[][] {
const rows: string[][] = []; let row: string[] = [], cell = "", q = false;
if (text.charCodeAt(0) === 0xFEFF) text = text.slice(1);
for (let i = 0; i < text.length; i++) {
const ch = text[i];
if (q) { if (ch === '"') { if (text[i + 1] === '"') { cell += '"'; i++; } else q = false; } else cell += ch; continue; }
if (ch === '"') q = true;
else if (ch === ",") { row.push(cell); cell = ""; }
else if (ch === "\n" || ch === "\r") { if (ch === "\r" && text[i + 1] === "\n") i++; row.push(cell); rows.push(row); row = []; cell = ""; }
else cell += ch;
}
if (cell !== "" || row.length) { row.push(cell); rows.push(row); }
return rows.filter((r) => r.some((c) => c !== ""));
}

View File

@ -0,0 +1,51 @@
import { EPICS, KEY_RE, NO_EPIC } from "../constants.ts";
import type { BoardModel, BoardNode, Dicts, Link } from "../types.ts";
import { parseCsv } from "./csv.ts";
import { addLink, addStubs } from "./links.ts";
import { sortableDate } from "./util.ts";
// CSV из фильтра Jira: нужны колонки Ключ проблемы, Тема, Статус, Приоритет, Тип задачи, Метки, Связи Фичи, Плановая дата окончания, Исполнитель, Спринт
// и «Внешняя/Внутренняя ссылка на проблему (ASUPR_…)». У задачи X «Внешняя ссылка (ASUPR_Блокер_Начала)» содержит Y, если X блокирует Y
// (outward-описание «Блокирует начало выполнения»); «Внутренняя ссылка» — если Y блокирует X. До 11.10.2026 связи в Jira стояли
// задом наперёд (ошибка направления в jira_sync.py), и страница читала «Внешняя» как блокеров; 11.10.2026 связи пересозданы, чтение исправлено.
export function fromCsv(text: string, fileName: string, dicts: Dicts): BoardModel {
const rows = parseCsv(text);
if (rows.length < 2) throw new Error("в файле нет строк с задачами");
const head = rows[0].map((h) => h.trim());
const find = (...subs: string[]) => head.findIndex((h) => subs.some((s) => h.toLowerCase().includes(s.toLowerCase())));
const cols = {
key: find("Ключ проблемы", "Ключ задачи", "Issue key", "Ключ"), sum: find("Тема", "Резюме", "Summary"), status: find("Статус", "Status"),
prio: find("Приоритет", "Priority"), type: find("Тип задачи", "Тип запроса", "Issue Type"), epic: find("Связи Фичи"),
due: find("Плановая дата окончания", "Срок исполнения", "Due date"), who: find("Исполнитель", "Assignee"),
};
const labelCols = head.map((h, i) => (/^(Метки|Labels)$/i.test(h) ? i : -1)).filter((i) => i >= 0);
const sprintCols = head.map((h, i) => (/^(Спринт|Sprint)$/i.test(h) ? i : -1)).filter((i) => i >= 0);
if (cols.key < 0 || cols.sum < 0) throw new Error("не нашёл колонки «Ключ проблемы» и «Тема»; в файле: " + head.slice(0, 10).join(", ") + "…");
const linkCols: { i: number; type: string; out: boolean }[] = [];
head.forEach((h, i) => { for (const [t, type] of Object.entries(dicts.linkTypes)) if (h.includes(t)) linkCols.push({ i, type, out: /outward|исход|внешн/.test(h.toLowerCase()) }); });
const problems: string[] = [];
if (!linkCols.length) problems.push("в файле нет колонок связей — в фильтре Jira добавьте колонку «Связанные задачи» или экспортируйте «все поля»");
if (!sprintCols.length) problems.push("в файле нет колонки «Спринт» — выбор спринта недоступен, все задачи считаются выбранными");
if (cols.epic < 0) problems.push("нет колонки «Связи Фичи» — дорожки по эпикам не построить");
const cell = (r: string[], i: number) => (i >= 0 ? r[i] || "" : "").trim();
const isEpic = (r: string[]) => cols.type >= 0 && /^(Фича|Epic|Эпик)$/i.test(cell(r, cols.type));
const epicNames: Record<string, string> = { ...EPICS };
rows.slice(1).forEach((r) => { const k = cell(r, cols.key); if (KEY_RE.test(k) && isEpic(r)) epicNames[k] = cell(r, cols.sum) || k; });
const nodes: BoardNode[] = [], links: Link[] = [], seen = new Set<string>(), sprints: Record<string, number> = {};
for (const r of rows.slice(1)) {
const key = cell(r, cols.key); if (!KEY_RE.test(key) || isEpic(r)) continue;
let kind = cell(r, cols.type);
for (const c of labelCols) { const v = cell(r, c); if (dicts.kindLabels[v]) kind = dicts.kindLabels[v]; }
const epicKey = cell(r, cols.epic);
const sprint = sprintCols.map((c) => cell(r, c)).filter(Boolean).pop() || "";
if (sprint) sprints[sprint] = (sprints[sprint] || 0) + 1;
nodes.push({ key, title: cell(r, cols.sum), kind, prio: cell(r, cols.prio), due: sortableDate(cell(r, cols.due)), status: cell(r, cols.status),
epic: epicKey ? (epicNames[epicKey] || epicKey) : NO_EPIC, sprint, who: cell(r, cols.who), stub: false });
for (const lc of linkCols) {
const other = cell(r, lc.i); if (!KEY_RE.test(other)) continue;
addLink(links, seen, lc.out ? key : other, lc.out ? other : key, lc.type);
}
}
const missing = addStubs(nodes, links);
return { nodes, links, sprints, fileName, problems, hasSprint: sprintCols.length > 0, missing };
}

View File

@ -0,0 +1,30 @@
import type { BoardIssue, BoardSnapshot } from "../../api/types.ts";
import { EPICS, KEY_RE, NO_EPIC, NOT_IN_HUB } from "../constants.ts";
import type { BoardModel, BoardNode, Dicts, Link } from "../types.ts";
import { addLink, addStubs } from "./links.ts";
import { sortableDate } from "./util.ts";
// снимок хаба (GET /board) → модель доски. Эпики («Фича»/Epic) — имена дорожек, не карточки; соседи по связям — обычные карточки;
// вид работы — первая метка kind-*, иначе тип задачи; срок — плановая дата окончания, иначе срок исполнения; связи — только известных
// типов (dicts.linkTypes), между ключами вида AURORA-1234 (ключ идёт в разметку карточки) и не с эпиками; ключи из связей, которых нет в зеркале, — заглушки на дорожке «Нет в зеркале»
export function fromHub(snap: BoardSnapshot, dicts: Dicts): BoardModel {
const isEpic = (it: BoardIssue) => /^(Фича|Epic|Эпик)$/i.test(it.issue_type || "");
const epicNames: Record<string, string> = { ...EPICS }, epics = new Set<string>();
snap.issues.forEach((it) => { if (isEpic(it)) { epics.add(it.key); epicNames[it.key] = it.summary || it.key; } });
const nodes: BoardNode[] = [], links: Link[] = [], seen = new Set<string>(), sprints: Record<string, number> = {};
snap.issues.forEach((it) => {
if (isEpic(it) || !KEY_RE.test(it.key)) return;
const label = (it.labels || []).find((l) => dicts.kindLabels[l]), sprint = it.sprint || "";
if (sprint) sprints[sprint] = (sprints[sprint] || 0) + 1;
nodes.push({ key: it.key, title: it.summary || "", kind: label ? dicts.kindLabels[label] : (it.issue_type || ""), prio: it.priority || "",
due: sortableDate(it.planned_end || it.duedate || ""), status: it.status || "",
epic: it.epic_key ? (epicNames[it.epic_key] || it.epic_key) : NO_EPIC, sprint, who: it.assignee || "", stub: false });
});
snap.links.forEach((l) => {
const type = dicts.linkTypes[l.link_type];
if (type && KEY_RE.test(l.source) && KEY_RE.test(l.target) && !epics.has(l.source) && !epics.has(l.target)) addLink(links, seen, l.source, l.target, type);
});
const missing = addStubs(nodes, links, NOT_IN_HUB);
const problems = snap.last_sync ? [] : ["в хабе ещё не было полного обновления из Jira — нажмите «Обновить из Jira»"];
return { nodes, links, sprints, fileName: "Хаб", problems, hasSprint: true, missing, stubLane: NOT_IN_HUB };
}

View File

@ -0,0 +1,36 @@
import { CW, KEY_RE, NO_EPIC } from "../constants.ts";
import type { BoardNode, Dicts, JsonImport, Link, SavedBox } from "../types.ts";
import { addLink, addStubs } from "./links.ts";
import { strList } from "../storage.ts";
import { sortableDate } from "./util.ts";
type Raw = Record<string, any>; // eslint-disable-line @typescript-eslint/no-explicit-any -- внешний JSON произвольной формы
// JSON, выгруженный старой страницей (см. exportJson): задачи, связи, раскладка, порядок и свёртка дорожек, фильтры
export function fromJson(text: string, fileName: string, dicts: Dicts): JsonImport {
let d: Raw; try { d = JSON.parse(text); } catch { throw new Error("это не JSON"); }
if (!d || !Array.isArray(d.nodes)) throw new Error("в JSON нет списка nodes — это не выгрузка этой страницы");
const keys = new Set<string>(), nodes: BoardNode[] = [], links: Link[] = [], seen = new Set<string>(), sprints: Record<string, number> = {};
const layout: Record<string, SavedBox> = {}, layoutByDate: Record<string, SavedBox> = {};
d.nodes.forEach((n: Raw) => {
const key = String(n && n.key || "").trim(); if (!KEY_RE.test(key) || keys.has(key) || n.stub) return; keys.add(key);
const sprint = String(n.sprint || "").trim(); if (sprint) sprints[sprint] = (sprints[sprint] || 0) + 1;
nodes.push({ key, title: String(n.title || ""), kind: String(n.kind || ""), prio: String(n.priority || ""), due: sortableDate(String(n.due || "")),
status: String(n.status || ""), epic: String(n.epic || NO_EPIC), sprint, who: String(n.assignee || ""), stub: false });
const take = (L: Raw | undefined): SavedBox | null => L && typeof L.x === "number" && typeof L.ry === "number" ? { x: L.x, ry: L.ry, w: typeof L.w === "number" ? L.w : CW, ...(typeof L.h === "number" ? { h: L.h } : {}) } : null;
const L = take(n.layout), LD = take(n.layoutByDate);
if (L) layout[key] = L;
if (LD) layoutByDate[key] = LD;
});
if (!nodes.length) throw new Error("в nodes нет ни одной задачи с ключом вида AURORA-1234");
const types = new Set(Object.values(dicts.linkTypes));
(Array.isArray(d.links) ? d.links : []).forEach((l: Raw) => {
const src = String(l && l.from || "").trim(), dst = String(l && l.to || "").trim(); if (!KEY_RE.test(src) || !KEY_RE.test(dst)) return;
addLink(links, seen, src, dst, types.has(l.type) ? l.type : "блокирует начало");
});
const missing = addStubs(nodes, links);
const f: Record<string, unknown> = d.filters && typeof d.filters === "object" ? { ...d.filters } : {};
if (f.mode === "linked") f.neighbors = true; // старые выгрузки: режим «спринт и связанные» = соседи (так в эталоне старой страницы)
return { model: { nodes, links, sprints, fileName: String(d.source || fileName), problems: [], hasSprint: nodes.some((n) => n.sprint), missing },
layout, layoutByDate, dateColumns: d.dateColumns && typeof d.dateColumns === "object" ? d.dateColumns : null, laneOrder: strList(d.laneOrder), collapsedLanes: Array.isArray(d.collapsedLanes) ? strList(d.collapsedLanes) : null, filters: f };
}

View File

@ -0,0 +1,18 @@
import { NOT_IN_FILE, RELATED, STUB_TEXT } from "../constants.ts";
import type { BoardNode, Link } from "../types.ts";
export const stubNode = (key: string, lane: string): BoardNode =>
({ key, title: STUB_TEXT[lane], kind: "", prio: "", due: "", status: "", epic: lane, sprint: "", stub: true, who: "" });
// связи без дублей; для «связанных» порядок концов не важен
export function addLink(links: Link[], seen: Set<string>, src: string, dst: string, type: string): void {
const id = type === RELATED ? [src, dst].sort().join("|") + type : src + ">" + dst + type;
if (seen.has(id)) return; seen.add(id); links.push({ src, dst, type });
}
// задачи, упомянутые в связях, но отсутствующие в источнике, добавляются заглушками на служебную дорожку lane
export function addStubs(nodes: BoardNode[], links: Link[], lane: string = NOT_IN_FILE): number {
const keys = new Set(nodes.map((n) => n.key)), missing = new Set<string>();
links.forEach((l) => [l.src, l.dst].forEach((k) => { if (!keys.has(k)) missing.add(k); }));
missing.forEach((k) => nodes.push(stubNode(k, lane)));
return missing.size;
}

View File

@ -0,0 +1,45 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import type { BoardSnapshot } from "../../api/types.ts";
import { canon } from "../../test/canon.ts";
import { dictsFromSettings } from "../dicts.ts";
import settings from "../__fixtures__/settings.json";
import { parseCsv } from "./csv.ts";
import { fromCsv } from "./fromCsv.ts";
import { fromHub } from "./fromHub.ts";
import { fromJson } from "./fromJson.ts";
const fx = (name: string) => readFileSync(join(import.meta.dirname, "..", "__fixtures__", name), "utf8");
const golden = (name: string) => JSON.parse(fx(name));
const { dicts } = dictsFromSettings(settings);
describe("модель доски = links.html@3931a88", () => {
it("fromHub — снимок с эпиками, соседями, заглушками", () => {
const snap = JSON.parse(fx("hub-basic.board.json")) as BoardSnapshot;
expect(canon(fromHub(snap, dicts))).toEqual(golden("hub-basic.expected.json").model);
});
it("fromHub — пустое зеркало без полного прогона", () => {
const snap = JSON.parse(fx("hub-empty.board.json")) as BoardSnapshot;
expect(canon(fromHub(snap, dicts))).toEqual(golden("hub-empty.expected.json").model);
});
it("parseCsv и fromCsv — BOM, CRLF, кавычки, повторные колонки, даты разных локалей", () => {
const text = fx("jira-export.csv");
const g = golden("jira-export.expected.json");
expect(parseCsv(text)).toEqual(g.rows);
expect(canon(fromCsv(text, "jira-export.csv", dicts))).toEqual(g.model);
});
it("fromCsv без колонок связей, спринта и эпика — проблемы файла", () => {
expect(canon(fromCsv(fx("jira-no-links.csv"), "jira-no-links.csv", dicts))).toEqual(golden("jira-no-links.expected.json").model);
});
it("fromCsv без ключа и темы — та же ошибка", () => {
expect(() => fromCsv(fx("jira-bad.csv"), "jira-bad.csv", dicts)).toThrow(golden("jira-bad.expected.json").error);
});
it("fromJson — выгрузка старой страницы", () => {
expect(canon(fromJson(fx("old-board.json"), "old-board.json", dicts))).toEqual(golden("old-board.expected.json").import);
});
it("fromJson — не JSON и JSON без nodes", () => {
expect(() => fromJson("{", "x.json", dicts)).toThrow("это не JSON");
expect(() => fromJson("{}", "x.json", dicts)).toThrow("в JSON нет списка nodes");
});
});

View File

@ -0,0 +1,22 @@
import { describe, expect, it } from "vitest";
import { dayDiff, fmtDate, plural, shortName, sortableDate } from "./util.ts";
describe("util", () => {
it("sortableDate приводит даты Jira к ГГГГ-ММ-ДД", () => {
expect(sortableDate("2026-10-20T10:00:00")).toBe("2026-10-20");
expect(sortableDate("5.1.2026")).toBe("2026-01-05");
expect(sortableDate("20/окт/26")).toBe("2026-10-20");
expect(sortableDate("10/Oct/2026")).toBe("2026-10-10");
expect(sortableDate("потом")).toBe("потом");
});
it("shortName, plural, fmtDate, dayDiff", () => {
expect(shortName("Степанов Максим Александрович")).toBe("Степанов М. А.");
expect(shortName("Stepanov-MAl")).toBe("Stepanov-MAl");
expect(shortName("")).toBe("не назначен");
expect(plural(21, "задача", "задачи", "задач")).toBe("21 задача");
expect(plural(12, "задача", "задачи", "задач")).toBe("12 задач");
expect(fmtDate("2026-10-20")).toBe("20.10");
expect(dayDiff("2026-10-14", "2026-10-20")).toBe(6);
expect(dayDiff("", "2026-10-20")).toBeNull();
});
});

View File

@ -0,0 +1,40 @@
import type { BoardModel } from "../types.ts";
export function plural(n: number, one: string, few: string, many: string): string {
return `${n} ${n % 10 === 1 && n % 100 !== 11 ? one : n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 10 || n % 100 >= 20) ? few : many}`;
}
export function fmtDate(d: string): string {
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(d);
return m ? `${m[3]}.${m[2]}` : d;
}
export function dayDiff(a: string, b: string): number | null {
return (!a || !b) ? null : Math.round((new Date(b).getTime() - new Date(a).getTime()) / 86400000);
}
export const notStarted = (status: string): boolean => /ожида|backlog|бэклог|к выполнению|to do|open/i.test(status || "");
export const unassigned = (w: string): boolean => !w || /^(не назначен|unassigned)$/i.test(w);
export const whoKey = (w: string): string => unassigned(w) ? "" : w.trim();
// «Степанов Максим Александрович» → «Степанов М. А.»; логины вида Stepanov-MAl остаются как есть
export function shortName(w: string): string {
if (unassigned(w)) return "не назначен";
const parts = w.trim().split(/\s+/); if (parts.length < 2) return w;
return parts[0] + " " + parts.slice(1, 3).map((x) => x[0].toUpperCase() + ".").join(" ");
}
const MONTHS: Record<string, string> = { jan: "01", feb: "02", mar: "03", apr: "04", may: "05", jun: "06", jul: "07", aug: "08", sep: "09", oct: "10", nov: "11", dec: "12", янв: "01", фев: "02", мар: "03", апр: "04", май: "05", мая: "05", июн: "06", июл: "07", авг: "08", сен: "09", окт: "10", ноя: "11", дек: "12" };
// даты Jira в разных локалях → ГГГГ-ММ-ДД, чтобы сравнивать строками
export function sortableDate(d: string): string {
let m = /^(\d{4})-(\d{2})-(\d{2})/.exec(d); if (m) return d.slice(0, 10);
m = /^(\d{1,2})[./](\d{1,2})[./](\d{4})/.exec(d); if (m) return `${m[3]}-${m[2].padStart(2, "0")}-${m[1].padStart(2, "0")}`;
m = /^(\d{1,2})\/([A-Za-zА-Яа-я]{3})\/(\d{2})(\d{2})?/.exec(d);
if (m) {
const mon = MONTHS[m[2].toLowerCase()];
if (mon) return `${m[4] ? m[3] + m[4] : "20" + m[3]}-${mon}-${m[1].padStart(2, "0")}`;
}
return d;
}
export const fmtStamp = (iso: string): string => {
const d = new Date(iso);
return isNaN(d.getTime()) ? String(iso) : d.toLocaleString("ru-RU", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" }).replace(",", "");
};
export const emptyModel = (text: string): BoardModel =>
({ nodes: [], links: [], sprints: {}, fileName: "", problems: [], hasSprint: false, missing: 0, emptyText: text });
export const todayIso = (): string => new Date().toISOString().slice(0, 10);

View File

@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import { statusText } from "./status.ts";
describe("statusText", () => {
it("пакет: офлайн, очередь, остаток, повтор", () => {
expect(statusText({ kind: "batch", id: 7, phase: "queued", remaining: 12, agentOffline: true, retrying: false }))
.toEqual({ text: "агент офлайн — пакет выполнится, когда он выйдет на связь; пакет #7 в очереди, осталось ~12 с…", warn: false });
expect(statusText({ kind: "batch", id: 7, phase: "running", remaining: 0, agentOffline: false, retrying: true }))
.toEqual({ text: "пакет #7 выполняется… (хаб не отвечает, повтор через 3 с)", warn: true });
});
it("исходы и ошибки", () => {
expect(statusText({ kind: "batch-failed", id: 7, error: "429" })).toEqual({ text: "пакет #7 не выполнен: 429", warn: true });
expect(statusText({ kind: "batch-lost", id: 7, reason: "пакета нет в хабе" }).text)
.toBe("пакет #7: пакета нет в хабе — опрос остановлен, «Обновить из Jira» снова доступна");
expect(statusText({ kind: "hub-down", hasData: true }).text).toBe("хаб не отвечает — показаны прежние данные");
expect(statusText({ kind: "file-error", fileName: "a.csv", reason: "в файле нет строк с задачами" }).text)
.toBe("файл не разобран: в файле нет строк с задачами");
});
});

38
web/src/board/status.ts Normal file
View File

@ -0,0 +1,38 @@
export const POLL_MS = 3000; // период опроса пакета, мс (как у links.html)
export const MAX_POLL_ERRORS = 5; // столько ошибок «хаб не отвечает» подряд — и опрос пакета останавливается (M-4)
export type BoardStatus =
| { kind: "loading" }
| { kind: "batch"; id: number; phase: "queued" | "running"; remaining: number; agentOffline: boolean; retrying: boolean }
| { kind: "batch-done"; id: number }
| { kind: "batch-failed"; id: number; error: string }
| { kind: "batch-cancelled"; id: number }
| { kind: "batch-lost"; id: number; reason: string }
| { kind: "hub-down"; hasData: boolean }
| { kind: "file-error"; fileName: string; reason: string }
| { kind: "json-applied"; fileName: string }
| { kind: "error"; message: string };
// одна строка состояния доски: текст и признак предупреждения (M-5)
export function statusText(s: BoardStatus): { text: string; warn: boolean } {
switch (s.kind) {
case "loading": return { text: "загрузка данных хаба…", warn: false };
case "batch": {
const off = s.agentOffline ? "агент офлайн — пакет выполнится, когда он выйдет на связь; " : "";
const left = s.remaining ? `, осталось ~${s.remaining} с` : "";
const retry = s.retrying ? ` (хаб не отвечает, повтор через ${POLL_MS / 1000} с)` : "";
return { text: `${off}пакет #${s.id} ${s.phase === "queued" ? "в очереди" : "выполняется"}${left}…${retry}`, warn: s.retrying };
}
case "batch-done": return { text: `пакет #${s.id} выполнен`, warn: false };
case "batch-failed": return { text: `пакет #${s.id} не выполнен: ${s.error}`, warn: true };
case "batch-cancelled": return { text: `пакет #${s.id} отменён`, warn: true };
case "batch-lost": return { text: `пакет #${s.id}: ${s.reason} — опрос остановлен, «Обновить из Jira» снова доступна`, warn: true };
case "hub-down": return {
text: s.hasData ? "хаб не отвечает — показаны прежние данные" : "хаб не отвечает: запустите хаб (jira-hub serve) и нажмите «Перечитать»",
warn: true,
};
case "file-error": return { text: `файл не разобран: ${s.reason}`, warn: true };
case "json-applied": return { text: `раскладка и фильтры из ${s.fileName} применены к данным хаба`, warn: false };
case "error": return { text: s.message, warn: true };
}
}

Some files were not shown because too many files have changed in this diff Show More