Веб-приложение хаба на React: доска связей и задачи (#21) #27
4
.gitignore
vendored
4
.gitignore
vendored
@ -12,3 +12,7 @@ __pycache__/
|
||||
*.sqlite*
|
||||
.pytest_cache/
|
||||
.wt/
|
||||
src/jira_hub/web/app/
|
||||
web/test-results/
|
||||
web/playwright-report/
|
||||
web/.e2e-hub/
|
||||
|
||||
@ -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`).
|
||||
|
||||
@ -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
10
Makefile
Normal 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
|
||||
28
README.md
28
README.md
@ -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/).
|
||||
|
||||
## Контракт команд
|
||||
|
||||
|
||||
5268
docs/superpowers/plans/2026-10-14-web-app-hub.md
Normal file
5268
docs/superpowers/plans/2026-10-14-web-app-hub.md
Normal file
File diff suppressed because it is too large
Load Diff
2
openspec/changes/web-app/.openspec.yaml
Normal file
2
openspec/changes/web-app/.openspec.yaml
Normal file
@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-10-14
|
||||
195
openspec/changes/web-app/design.md
Normal file
195
openspec/changes/web-app/design.md
Normal 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 |
|
||||
53
openspec/changes/web-app/proposal.md
Normal file
53
openspec/changes/web-app/proposal.md
Normal 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.
|
||||
66
openspec/changes/web-app/specs/hub-api/spec.md
Normal file
66
openspec/changes/web-app/specs/hub-api/spec.md
Normal 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` читаются приложением под теми же ключами.
|
||||
165
openspec/changes/web-app/specs/web-app/spec.md
Normal file
165
openspec/changes/web-app/specs/web-app/spec.md
Normal 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` не показывает файлов сборки
|
||||
@ -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
@ -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")
|
||||
|
||||
@ -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
113
tests/test_web_app.py
Normal 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
59
web/e2e/board.spec.ts
Normal 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
81
web/e2e/geometry.spec.ts
Normal 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
28
web/e2e/hub.ts
Normal 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
15
web/e2e/issues.spec.ts
Normal 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
17
web/e2e/login.spec.ts
Normal 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
16
web/eslint.config.js
Normal 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
14
web/index.html
Normal 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
3892
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
39
web/package.json
Normal file
39
web/package.json
Normal 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
18
web/playwright.config.ts
Normal 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" },
|
||||
},
|
||||
});
|
||||
195
web/scripts/golden-from-links.ts
Normal file
195
web/scripts/golden-from-links.ts
Normal 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
21
web/scripts/seed-hub.py
Normal 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
20
web/scripts/test-hub.sh
Normal 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
56
web/src/App.test.tsx
Normal 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
23
web/src/App.tsx
Normal 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;
|
||||
57
web/src/api/client.test.ts
Normal file
57
web/src/api/client.test.ts
Normal 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
60
web/src/api/client.ts
Normal 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
46
web/src/api/queries.ts
Normal 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 });
|
||||
},
|
||||
});
|
||||
}
|
||||
13
web/src/api/queryClient.ts
Normal file
13
web/src/api/queryClient.ts
Normal 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
54
web/src/api/types.ts
Normal 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 }
|
||||
9
web/src/auth/AuthGate.tsx
Normal file
9
web/src/auth/AuthGate.tsx
Normal 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 />;
|
||||
}
|
||||
65
web/src/auth/LoginScreen.test.tsx
Normal file
65
web/src/auth/LoginScreen.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
48
web/src/auth/LoginScreen.tsx
Normal file
48
web/src/auth/LoginScreen.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
40
web/src/auth/token.test.ts
Normal file
40
web/src/auth/token.test.ts
Normal 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
33
web/src/auth/token.ts
Normal 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
4
web/src/auth/useAuth.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { getAuth, subscribe, type AuthSnapshot } from "./token.ts";
|
||||
|
||||
export const useAuth = (): AuthSnapshot => useSyncExternalStore(subscribe, getAuth);
|
||||
135
web/src/board/BoardPage.test.tsx
Normal file
135
web/src/board/BoardPage.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
90
web/src/board/BoardPage.tsx
Normal file
90
web/src/board/BoardPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
12
web/src/board/__fixtures__/geometry.cases.json
Normal file
12
web/src/board/__fixtures__/geometry.cases.json
Normal 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}]}
|
||||
56
web/src/board/__fixtures__/geometry.expected.json
Normal file
56
web/src/board/__fixtures__/geometry.expected.json
Normal 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"
|
||||
]
|
||||
}
|
||||
29
web/src/board/__fixtures__/hub-basic.board.json
Normal file
29
web/src/board/__fixtures__/hub-basic.board.json
Normal 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_Блокер_Начала"}
|
||||
]
|
||||
}
|
||||
8014
web/src/board/__fixtures__/hub-basic.expected.json
Normal file
8014
web/src/board/__fixtures__/hub-basic.expected.json
Normal file
File diff suppressed because it is too large
Load Diff
1
web/src/board/__fixtures__/hub-empty.board.json
Normal file
1
web/src/board/__fixtures__/hub-empty.board.json
Normal file
@ -0,0 +1 @@
|
||||
{"generated_at": "2026-10-14T10:00:00+00:00", "last_sync": null, "issues": [], "links": []}
|
||||
93
web/src/board/__fixtures__/hub-empty.expected.json
Normal file
93
web/src/board/__fixtures__/hub-empty.expected.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
2
web/src/board/__fixtures__/jira-bad.csv
Normal file
2
web/src/board/__fixtures__/jira-bad.csv
Normal file
@ -0,0 +1,2 @@
|
||||
Номер,Название
|
||||
1,x
|
||||
|
3
web/src/board/__fixtures__/jira-bad.expected.json
Normal file
3
web/src/board/__fixtures__/jira-bad.expected.json
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"error": "не нашёл колонки «Ключ проблемы» и «Тема»; в файле: Номер, Название…"
|
||||
}
|
||||
7
web/src/board/__fixtures__/jira-export.csv
Normal file
7
web/src/board/__fixtures__/jira-export.csv
Normal 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,мусор,,,,,,,,,,,,,
|
||||
|
228
web/src/board/__fixtures__/jira-export.expected.json
Normal file
228
web/src/board/__fixtures__/jira-export.expected.json
Normal 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",
|
||||
"мусор",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
""
|
||||
]
|
||||
]
|
||||
}
|
||||
2
web/src/board/__fixtures__/jira-no-links.csv
Normal file
2
web/src/board/__fixtures__/jira-no-links.csv
Normal file
@ -0,0 +1,2 @@
|
||||
Issue key,Summary,Status
|
||||
AURORA-9001,Тема,Open
|
||||
|
28
web/src/board/__fixtures__/jira-no-links.expected.json
Normal file
28
web/src/board/__fixtures__/jira-no-links.expected.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
131
web/src/board/__fixtures__/old-board.expected.json
Normal file
131
web/src/board/__fixtures__/old-board.expected.json
Normal 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": "[\"Петров Пётр Петрович\"]"
|
||||
}
|
||||
}
|
||||
11
web/src/board/__fixtures__/old-board.json
Normal file
11
web/src/board/__fixtures__/old-board.json
Normal 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": "связанные"}]}
|
||||
14
web/src/board/__fixtures__/scenarios.json
Normal file
14
web/src/board/__fixtures__/scenarios.json
Normal 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}}}
|
||||
]
|
||||
5
web/src/board/__fixtures__/settings.json
Normal file
5
web/src/board/__fixtures__/settings.json
Normal 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"}
|
||||
}
|
||||
6
web/src/board/__fixtures__/storage.cases.json
Normal file
6
web/src/board/__fixtures__/storage.cases.json
Normal 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"}}
|
||||
]
|
||||
20
web/src/board/__fixtures__/storage.expected.json
Normal file
20
web/src/board/__fixtures__/storage.expected.json
Normal 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"
|
||||
}
|
||||
}
|
||||
54
web/src/board/analysis/analysis.test.ts
Normal file
54
web/src/board/analysis/analysis.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
40
web/src/board/analysis/critical.ts
Normal file
40
web/src/board/analysis/critical.ts
Normal 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;
|
||||
}
|
||||
62
web/src/board/analysis/facets.ts
Normal file
62
web/src/board/analysis/facets.ts
Normal 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));
|
||||
}
|
||||
20
web/src/board/analysis/problems.ts
Normal file
20
web/src/board/analysis/problems.ts
Normal 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 };
|
||||
}
|
||||
59
web/src/board/analysis/visible.ts
Normal file
59
web/src/board/analysis/visible.ts
Normal 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
162
web/src/board/board.css
Normal 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}
|
||||
114
web/src/board/components/BoardCanvas.test.tsx
Normal file
114
web/src/board/components/BoardCanvas.test.tsx
Normal 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/);
|
||||
});
|
||||
});
|
||||
185
web/src/board/components/BoardCanvas.tsx
Normal file
185
web/src/board/components/BoardCanvas.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
50
web/src/board/components/Card.tsx
Normal file
50
web/src/board/components/Card.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
44
web/src/board/components/DatesBar.tsx
Normal file
44
web/src/board/components/DatesBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
23
web/src/board/components/Dropdown.tsx
Normal file
23
web/src/board/components/Dropdown.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
38
web/src/board/components/Edges.tsx
Normal file
38
web/src/board/components/Edges.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
20
web/src/board/components/ExportBox.tsx
Normal file
20
web/src/board/components/ExportBox.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
27
web/src/board/components/FilterBar.tsx
Normal file
27
web/src/board/components/FilterBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
52
web/src/board/components/Lane.tsx
Normal file
52
web/src/board/components/Lane.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
25
web/src/board/components/Legend.tsx
Normal file
25
web/src/board/components/Legend.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
13
web/src/board/components/LinkTable.tsx
Normal file
13
web/src/board/components/LinkTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
40
web/src/board/components/MultiSelect.tsx
Normal file
40
web/src/board/components/MultiSelect.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
30
web/src/board/components/ProblemsDrawer.tsx
Normal file
30
web/src/board/components/ProblemsDrawer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
84
web/src/board/components/Toolbar.tsx
Normal file
84
web/src/board/components/Toolbar.tsx
Normal 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>;
|
||||
}
|
||||
14
web/src/board/constants.ts
Normal file
14
web/src/board/constants.ts
Normal 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;
|
||||
15
web/src/board/dicts.test.ts
Normal file
15
web/src/board/dicts.test.ts
Normal 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
13
web/src/board/dicts.ts
Normal 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 };
|
||||
}
|
||||
35
web/src/board/exportJson.ts
Normal file
35
web/src/board/exportJson.ts
Normal 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`;
|
||||
}
|
||||
22
web/src/board/layout/align.ts
Normal file
22
web/src/board/layout/align.ts
Normal 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;
|
||||
}
|
||||
39
web/src/board/layout/edges.ts
Normal file
39
web/src/board/layout/edges.ts
Normal 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 }]);
|
||||
}
|
||||
74
web/src/board/layout/layout.test.ts
Normal file
74
web/src/board/layout/layout.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
128
web/src/board/layout/layout.ts
Normal file
128
web/src/board/layout/layout.ts
Normal 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) : "без срока");
|
||||
15
web/src/board/model/csv.ts
Normal file
15
web/src/board/model/csv.ts
Normal 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 !== ""));
|
||||
}
|
||||
51
web/src/board/model/fromCsv.ts
Normal file
51
web/src/board/model/fromCsv.ts
Normal 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 };
|
||||
}
|
||||
30
web/src/board/model/fromHub.ts
Normal file
30
web/src/board/model/fromHub.ts
Normal 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 };
|
||||
}
|
||||
36
web/src/board/model/fromJson.ts
Normal file
36
web/src/board/model/fromJson.ts
Normal 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 };
|
||||
}
|
||||
18
web/src/board/model/links.ts
Normal file
18
web/src/board/model/links.ts
Normal 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;
|
||||
}
|
||||
45
web/src/board/model/model.test.ts
Normal file
45
web/src/board/model/model.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
22
web/src/board/model/util.test.ts
Normal file
22
web/src/board/model/util.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
40
web/src/board/model/util.ts
Normal file
40
web/src/board/model/util.ts
Normal 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);
|
||||
19
web/src/board/status.test.ts
Normal file
19
web/src/board/status.test.ts
Normal 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
38
web/src/board/status.ts
Normal 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
Loading…
x
Reference in New Issue
Block a user