From 6a2d8b7f5ef91475938a262a697d38f0fef859f5 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 11:29:23 +0400 Subject: [PATCH 01/19] =?UTF-8?q?spec(web-modules):=20=D0=B2=D0=B5=D0=B1?= =?UTF-8?q?=20=D0=BF=D0=BE=D0=B4=20=D0=BC=D0=BE=D0=B4=D1=83=D0=BB=D0=B8=20?= =?UTF-8?q?=E2=80=94=20=D1=81=D0=BE=D1=81=D1=82=D0=BE=D1=8F=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=20=D0=BC=D0=BE=D0=B4=D1=83=D0=BB=D0=B5=D0=B9,=20=C2=AB?= =?UTF-8?q?=D0=9E=D0=B1=D0=B7=D0=BE=D1=80=C2=BB,=20=D0=B0=D0=B4=D1=80?= =?UTF-8?q?=D0=B5=D1=81=D0=B0=20=D0=BC=D0=BE=D0=B4=D1=83=D0=BB=D0=B5=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change web-modules по аудиту веба 24.10.2026 (#98). Co-Authored-By: Claude Opus 5.5 --- openspec/changes/web-modules/.openspec.yaml | 2 + openspec/changes/web-modules/design.md | 118 +++++++ openspec/changes/web-modules/proposal.md | 58 ++++ .../changes/web-modules/specs/hub-api/spec.md | 112 +++++++ .../changes/web-modules/specs/web-app/spec.md | 299 ++++++++++++++++++ 5 files changed, 589 insertions(+) create mode 100644 openspec/changes/web-modules/.openspec.yaml create mode 100644 openspec/changes/web-modules/design.md create mode 100644 openspec/changes/web-modules/proposal.md create mode 100644 openspec/changes/web-modules/specs/hub-api/spec.md create mode 100644 openspec/changes/web-modules/specs/web-app/spec.md diff --git a/openspec/changes/web-modules/.openspec.yaml b/openspec/changes/web-modules/.openspec.yaml new file mode 100644 index 0000000..1f5d280 --- /dev/null +++ b/openspec/changes/web-modules/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-24 diff --git a/openspec/changes/web-modules/design.md b/openspec/changes/web-modules/design.md new file mode 100644 index 0000000..5a0c5b5 --- /dev/null +++ b/openspec/changes/web-modules/design.md @@ -0,0 +1,118 @@ +## Context + +Основа — `main` после слияния sdlc-rename (#97): пакет `sdlc_hub`, бренд «SDLC Hub», ключи `sdlc-hub-*`. Ветка +`feat/98-web-modules` перебазируется на него до исполнения плана. + +Аудит веба 24.10.2026 (сабагент, код + живой хаб, скриншоты в scratchpad сессии) — главное: +- контракт `HubModule` (`web/src/modules/types.ts`): `name`, `pages`, `nav`, `Provider`, `SidebarStatus` + (компонент), `TopbarNotice`, `useOverlay`, `projectFields`, `useOnProjectCreated(batchId)`; +- `RefreshProvider` (модуль jira, 174 строки) — единственный трекер пакетов: подхват пакетов `viewer` с первой + командой `refresh` (по `GET /batches/{id}` на каждого кандидата), один опрос, остановка, перечитывание; агент — `GET + /agent` (только jira); время — `last_sync` из снимка `/board` (грузится на каждой странице); +- wiki: `SyncStatus` — текст из `GET /projects` + `GET /wiki/status`, кнопка только в шапке страницы; +- ядро знает jira: `/` → `/board` в модуле jira, типы jira и `fact_status` в `api/types.ts`, `api/closed.ts`, бренд; + модуль wiki импортирует `fmtStamp` из модуля jira; +- `modules/jira/issues/wiki/wiki.css` (`div.wiki h1…`) перебивает заголовки страницы «Вики» (корень `div.wiki`); +- `POST /projects` → один `batch_id`, оболочка раздаёт его всем модулям; +- API: `GET /agents` уже есть (все поля агента), `GET /projects` уже несёт свежесть wiki, у пакета нет модуля. + +## Goals / Non-Goals + +**Goals:** оболочка рисует состояние модулей по их описанию; пакеты и агенты отслеживает ядро; навигация и главная +страница — модульные; граница ядра и модулей в вебе проверяется тестами; найденные аудитом дефекты страниц исправлены. + +**Non-Goals и Anti-Goals:** см. proposal.md. + +## Decisions + +1. **Навигация — группы по модулям + «Обзор»** (вариант «А + Б» аудита). Группы оставляют все разделы на виду (их + четыре), «Обзор» даёт модульную главную вместо `/board` и место для сводки. Переключатель модулей (вариант «В») + отклонён: лишний щелчок между доской и вики ради масштаба, которого нет. +2. **Адреса с префиксом модуля** (`/app/jira/…`, `/app/wiki`) и перенаправления со старых. Делается сейчас, пока + ломается всё сразу (переименование); пути модулей не пересекаются по построению. Перенаправления объявляет модуль + jira (это его прежние адреса). `GET /` хаба — на `/app/`. +3. **Состояние модуля — данные (`ModuleStatus`), рисует ядро.** Компонент `SidebarStatus` убирается: из-за него блок и + был разнобойным. Свежесть берётся из уже загруженного `GET /projects` чистой функцией от проекта — без запросов + модуля: jira — `jira_synced_at` (новое поле, общее для проектов), wiki — `wiki_synced_at`/`wiki_sync_error`. +4. **Один трекер пакетов в ядре** (`shell/activity/`): обобщение `RefreshProvider` — состояние на модуль + (`event`, `active`, `busy`), опрос `GET /batches/{id}`, правила остановки прежние. Подхват — по полю `module` из + `GET /batches?module=` (новое), любого автора: автообновления хаба и пакеты из MCP теперь видны (С1/В1 аудита), а + подхват не делает запрос на каждого кандидата. Прежнее правило «только viewer + refresh» отменено (сценарий «Пакет с + другими командами» меняется: пакет показывается, но не блокирует кнопку). +5. **Кнопка недоступна только на время пакета своего действия обновления** (jira — первая команда `refresh`, wiki — + `wiki_sync`; тип виден в `GET /batches/{id}`, который трекер и так опрашивает). Иначе долгое скачивание вложения + блокировало бы «Обновить из Jira». +6. **Обновление: значок в строке модуля + вторичная кнопка в шапке страниц модуля**, primary для обновления нет. Сейчас + на «Вики» две зелёные кнопки (в панели и в шапке) — против правила AURA «одна primary на экран». +7. **Агенты — один опрос `GET /agents`** раз в 60 с; точка агента: на связи / офлайн / «обновите агента» (`warning` + или `update_hint`), всегда с текстом. +8. **Узкий экран**: в верхней строке «Меню», метка проекта и сводная точка; отдельная кнопка «Обновить из Jira» и + `TopbarNotice` из контракта убираются (событие пакета показывает ядро строкой под верхней строкой). Выезжающая + панель — модальная (#73 M-1…M-3). +9. **Пакеты нового проекта по модулям**: `POST`/`PATCH /projects` отдают `batches: [{module, id}]`; `batch_id` и + `batch_ids` остаются для MCP. Оболочка передаёт каждому модулю его пакет (снимает M-14/T5.3 #68 и потерю `wiki_sync`). +10. **Граница в вебе — тестами**: импорты (ядро → только `modules/index.ts`; модуль → не другой модуль), уникальность + имён модулей и `nav.to` (T7.5/T7.6 #90), пересечение имён классов CSS между модулями и с ядром. Префиксы классов + не вводятся: тест на пересечение ловит именно случившийся дефект и не требует переименовывать сотни классов. + `div.wiki` разметки Jira переименовывается в `.jira-markup`. +11. **Типы jira — в модуль jira** (`modules/jira/api/types.ts`), `closed.ts` — туда же; `fact_status` удаляется из веба + (веб-часть #60). В `api/types.ts` ядра остаются `Batch`, `AgentView` (полный вид из `GET /agents`), `Project`, + `CreatedProject`, `UpdatedProject`, `HubSettings` без словарей jira. +12. **Форматтеры дат — ядро** (`ui/format.ts`): `fmtStamp` («ДД.ММ ЧЧ:ММ»), `fmtDate` («ДД.ММ», не текущий год — + «ДД.ММ.ГГГГ»). Списки и карточка перестают выводить ISO. +13. **«Обзор» берёт сводку у модуля** (`Summary({project})`); счётчики jira — из снимка доски проекта (запрос только на + «Обзоре»); вики — `wiki_pages` из проекта. +14. **Порядок архива**: sdlc-rename архивируется раньше web-modules: дельты `web-app`/`hub-api` этого change написаны + поверх текстов после переименования. + +## Контракт веб-модуля (после change) + +```ts +type ModuleStatus = { + agent?: string; // имя модуля агента в GET /agents + scope: "all" | "project"; // охват свежести и обновления + freshness(p: Project): { available: boolean; at: string | null; error: string | null }; + refresh?: { + label: string; // «Обновить из Jira», «Обновить вики» + start(project: string): Promise<{ id: number; estimate_seconds?: number }>; // 409 → ApiError, ядро подхватит идущий + isOwn(b: Batch): boolean; // пакет этого действия (по commands[0].type) + }; + invalidate(qc: QueryClient, project: string | null): void; // что перечитать после пакета модуля +}; +type HubModule = { + name: string; title: string; // «jira» / «Jira» + pages: RouteObject[]; nav: NavItem[]; // nav.to — с префиксом модуля + status?: ModuleStatus; + Summary?: ComponentType<{ project: Project }>; // карточка «Обзора» + Provider?, useOverlay?, projectFields?; // как было; SidebarStatus, TopbarNotice, useOnProjectCreated — удалены +}; +``` + +## Risks / Trade-offs + +- **Подхват пакетов любого автора** покажет и пакеты, поставленные из чата (`claude`), — это желаемо, но строка «Jira» + будет чаще «занята». Кнопка при этом не блокируется (решение 5). +- **Счётчики на «Обзоре»** грузят снимок доски — тяжёлый запрос; только на этой странице и с тем же ключом кэша, что у + доски. +- **Смена адресов** ломает закладки — их держат перенаправления; внешних ссылок на веб нет (MCP веб-адресов не отдаёт). + +## Decisions made without discussion + +Владелец 24.10.2026 ответил «делай» на вопросы brainstorming — решения 1–14 выше приняты агентом по аудиту. + +| # | Дата | Решение | Обоснование | Цена ошибки | +|---|---|---|---|---| +| 1 | 24.10.2026 | Навигация — группы + «Обзор» | 4 раздела, 2 модуля; нужна модульная главная | переделка панели | +| 2 | 24.10.2026 | Адреса `/app/<модуль>/…` с перенаправлениями | заодно с переименованием, пути не пересекаются | вернуть пути, перенаправления наоборот | +| 3 | 24.10.2026 | Состояние модуля — данные, рисует ядро | разнобой блоков возник из компонента | — | +| 4 | 24.10.2026 | Трекер в ядре, подхват любого автора по `module` | видны автообновления и пакеты MCP | вернуть фильтр по автору | +| 5 | 24.10.2026 | Блокирует только пакет своего обновления | долгие пакеты не мешают обновлению | — | +| 6 | 24.10.2026 | Обновление — значок в панели + secondary в шапке | одна primary на экран | перекраска кнопок | +| 7 | 24.10.2026 | Один опрос `/agents` | агент wiki не виден сейчас | — | +| 8 | 24.10.2026 | Узкий экран: метка проекта + сводная точка | проект не виден, две кнопки обновления | вернуть кнопку | +| 9 | 24.10.2026 | `batches` по модулям, старые поля остаются | пакет wiki терялся; MCP не ломать | — | +| 10 | 24.10.2026 | Тест на пересечение классов CSS вместо префиксов | ловит дефект без массового переименования | ввести префиксы позже | +| 11 | 24.10.2026 | Типы jira — в модуль, `fact_status` из веба удалён | граница ядра; #60 | вернуть поле в тип | +| 12 | 24.10.2026 | Форматтеры дат в ядре | три формата дат, импорт wiki → jira | — | +| 13 | 24.10.2026 | Сводка «Обзора» от модуля | ядро не знает данных модулей | — | +| 14 | 24.10.2026 | Архив после sdlc-rename | дельты поверх новых имён | правка дельт при архиве | diff --git a/openspec/changes/web-modules/proposal.md b/openspec/changes/web-modules/proposal.md new file mode 100644 index 0000000..5ae0d76 --- /dev/null +++ b/openspec/changes/web-modules/proposal.md @@ -0,0 +1,58 @@ +## Why + +Веб хаба собирался, когда модуль был один. После hub-modules (#87) и wiki-module (#91) у хаба два модуля со своими +агентами, а оболочка по-прежнему говорит на языке Jira. Владелец 24.10.2026 заметил, что блок в боковой панели вводит в +заблуждение: «агент на связи» — это только агент jira, у вики нет ни агента, ни кнопки, неясно, к чему относится «данные +на …». Владелец попросил пересмотреть весь веб и организовать его под модули в новом имени sdlc-hub (#97). + +Аудит 24.10.2026 (код, спеки, живой хаб в двух темах и на 390 px) показал, что это не одна подпись: +- в контракте веб-модуля нет понятия «состояние модуля» (агент, свежесть данных и её охват, обновление, пакет в + работе): каждый модуль рисует свой блок по-своему, оболочка не может ни свести их, ни показать сводку; +- блок jira грузит полный снимок доски на каждой странице ради одного времени `last_sync`; +- ход пакетов отслеживает только jira; автообновления хаба и пакеты из MCP не видны, время данных отстаёт; +- оболочка привязана к jira: `/` ведёт на доску, типы jira и `closed.ts` лежат в ядре, модуль wiki импортирует + форматтер из модуля jira; +- CSS модулей не разделён: правило разметки Jira в карточке (`div.wiki h1`) ломает заголовки страницы «Вики»; +- пакет синхронизации вики нового проекта теряется: `POST /projects` отдаёт один `batch_id`; +- страница «Задачи» прокручивается по горизонтали на 1440 px, просрочка в таблице показана только цветом. + +## What Changes + +- **Состояние модуля — данные, а не компонент.** Модуль описывает: название для показа, агента, свежесть (время, охват + «все проекты» / «проект», ошибка), действие обновления и правило «чей пакет». Ядро рисует блок одинаково в боковой + панели, свёрнутой панели, на узком экране и на «Обзоре». +- **Ядро отслеживает пакеты и агентов.** Один опрос `GET /agents` для всех модулей; один трекер пакетов (вынос + `RefreshProvider` из модуля jira) — подхват незавершённого пакета модуля любого автора, тексты событий, 409, остановка + опроса, перечитывание данных модуля по завершении. +- **Навигация по модулям и «Обзор».** Боковая панель группирует разделы под заголовками модулей; новая страница ядра + «Обзор» (`/app/?project=`) — главная: карточка каждого модуля (состояние + сводка от модуля) и последние пакеты + проекта. +- **Адреса модулей с префиксом модуля:** `/app/jira/board`, `/app/jira/issues`, `/app/jira/issues/{key}`, + `/app/jira/feed`, `/app/wiki`; прежние адреса перенаправляют с сохранением параметров; `/` и `/links` хаба — на `/app/`. +- **API для блока состояния:** `GET /projects` — время последнего полного обновления Jira; `GET /batches` — модуль + пакета; `POST`/`PATCH /projects` — пакеты по модулям. +- **Граница ядра и модулей в вебе:** типы jira, `closed.ts` и словари jira уходят в модуль jira; форматтеры дат — в + ядро; классы CSS модуля не совпадают с классами другого модуля и ядра (тест-страж); `fact_status` удаляется из веба. +- **Доработки по аудиту:** заголовок вкладки «<страница> · <проект> · SDLC Hub»; окно «Новый проект» с группами полей + по модулям и подсказками под полем; узкий экран с меткой проекта и сводной точкой состояния, модальная выезжающая + панель; «Задачи» без горизонтальной прокрутки страницы, просрочка текстом; «Вики» открывает корень и показывает + понятную шапку; мелкие тексты («Команды хаба», подсказка выбора проекта). +- **Закрываемые follow-ups:** #94 (веб: T4.1, T4.2, T4.4, T4.5, T10.1–T10.7, T9.1), #90 (T7.5, T7.6), #73 (M-1…M-7), + #68 (N-1, N-2, M-2, M-12, M-14, M-17, M-18, T5.3), #35 (M-9, O-16; T3.1/M-10 — отметить сделанным). + +## Non-Goals + +- Переименование (#97) — делается раньше; этот change строится на `sdlc_hub`, «SDLC Hub», ключах `sdlc-hub-*`. +- Страница «Агенты и очередь», настройки хаба в вебе. +- Полное удаление «статуса по факту» из базы, API, MCP и агента (#60); здесь — только из типов веба. +- Разметка Jira в ленте (С6 аудита), сворачивание фильтров доски на телефоне (С12), 409 `no_root` у `/wiki/status` + (С10), доступность компонентов `ui/` (`Menu`, `Segmented`, `ThemeSwitch`), доработки окна карточки из #42, служебные + метки `fact-*`/`kind-*` в карточке — в follow-up. +- Изменения логики доски, списка задач, ленты и вики сверх перечисленного. + +## Anti-Goals + +- Блок состояния, который снова надо читать как загадку: у каждой строки должно быть видно, к какому модулю она + относится и что именно обновит кнопка (все проекты или текущий). +- Ядро веба, знающее про jira или wiki по имени: всё, что оболочка показывает о модуле, приходит из описания модуля. +- Две основные (primary) кнопки на одном экране. diff --git a/openspec/changes/web-modules/specs/hub-api/spec.md b/openspec/changes/web-modules/specs/hub-api/spec.md new file mode 100644 index 0000000..9a54369 --- /dev/null +++ b/openspec/changes/web-modules/specs/hub-api/spec.md @@ -0,0 +1,112 @@ +## MODIFIED Requirements + +### Requirement: Раздача веб-приложения +Хаб SHALL отдавать собранное веб-приложение (спека `web-app`) из каталога пакета `src/sdlc_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` в каталоге sdlc-hub; +- `GET /` и `GET /links` — 302 с `Location: /app/` (главная страница приложения — «Обзор»). +Сборка приложения не хранится в 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/` + +#### Scenario: Нулевой байт в пути +- **WHEN** запрошен `GET /app/assets/%00` или `GET /app/assets/a%00.js` +- **THEN** 404 `not_found`, а не 500 + +### Requirement: Маршруты проектов +Хаб SHALL предоставлять под клиентским токеном: +- `GET /projects` → список проектов по `created_at`, затем `label`; каждый — плоский объект из полей ядра (`label`, + `name`, `created_at`) и полей всех модулей (`modules`, «Поля проекта от модулей»). Модуль jira даёт `board_id`, + `board_type`, `issues_count` (задачи зеркала с меткой проекта), `jira_synced_at` (время завершения последнего полного + обновления зеркала, общее для всех проектов; `null`, если его не было), модуль wiki — `wiki_root`, `wiki_root_title`, + `wiki_pages`, `wiki_synced_at`, `wiki_sync_error`. +- `POST /projects` `{label, name, author?, <поля модулей>}` → 201 `{project, batch_id, batches}`: в одной транзакции + проект записывается, модули записывают свои поля и ставят свои пакеты; `batches` — список `{module, id}` всех + поставленных пакетов в порядке модулей; `batch_id` — пакет первого модуля, вернувшего пакет (у jira — «Новый проект <метка>» из одного `refresh` по базовой JQL, уже включающей новую метку). Автор пакетов — + `author` (по умолчанию `api`). Неизвестное поле, неверные поля ядра (см. `data-model`, «Проекты») или модуля — 422 с + указанием поля; метка, совпадающая с существующей без учёта регистра, — 409. При ошибке ни проекта, ни пакетов нет. +- `PATCH /projects/{label}` `{<изменяемые поля модулей>, author?}` → 200 `{project, batch_ids, batches}`: модули правят + свои поля и ставят пакеты в одной транзакции; `batches` — список `{module, id}`. Неизменяемое поле (метка, название, `board_id`) — 422 «<поле> после создания не + меняется»; неизвестное поле или неверное значение — 422 с указанием поля; проекта нет — 404. + +#### Scenario: Новый проект +- **WHEN** `POST /projects {label: "STOCKS_DRRK", name: "Запасы ДРРК", board_id: 5120, author: "viewer"}`, есть проект `AWPCA` +- **THEN** 201; `GET /projects` отдаёт два проекта, у `STOCKS_DRRK` `board_type = null`, `wiki_root = null`; в очереди + пакет автора `viewer` с `refresh {jql: 'project = AURORA AND labels in ("AWPCA", "STOCKS_DRRK")'}` + +#### Scenario: Проект без доски +- **WHEN** `POST /projects {label: "STOCKS_DRRK", name: "Запасы ДРРК"}` +- **THEN** 201, `board_id = null` + +#### Scenario: Проект с корнем вики +- **WHEN** `POST /projects {label: "STOCKS_DRRK", name: "Запасы ДРРК", wiki_root: "DOC-001234"}` +- **THEN** 201; в очереди пакет `refresh` и пакет `wiki_sync {label: "STOCKS_DRRK", root: "DOC-001234"}`; `batch_id` — + пакет `refresh`; `batches` — `[{module: "jira", id: }, {module: "wiki", id: }]` + +#### Scenario: Повтор метки +- **WHEN** `POST /projects {label: "AWPCA", name: "…"}`, проект `AWPCA` есть +- **THEN** 409, пакет не поставлен + +#### Scenario: Без названия +- **WHEN** `POST /projects {label: "STOCKS_DRRK", name: " "}` +- **THEN** 422 с указанием поля `name` + +#### Scenario: Неизвестное поле +- **WHEN** `POST /projects {label: "X", name: "X", board: 5}` +- **THEN** 422 с указанием поля `board`, проекта нет + +#### Scenario: Корень вики у существующего проекта +- **WHEN** `PATCH /projects/AWPCA {wiki_root: "DOC-000777"}` +- **THEN** 200, `batch_ids` — один пакет `wiki_sync`, `batches` — `[{module: "wiki", id: <он же>}]`; `GET /projects` отдаёт `AWPCA` с `wiki_root = DOC-000777` + +#### Scenario: Доску не меняют +- **WHEN** `PATCH /projects/AWPCA {board_id: 1}` +- **THEN** 422 «board_id после создания не меняется», доска прежняя + +#### Scenario: Свежесть Jira в проекте +- **WHEN** последний полный пакет `refresh` завершён в 11:07, `GET /projects` +- **THEN** у каждого проекта `jira_synced_at` — это время; до первого полного обновления — `null` + +## ADDED Requirements + +### Requirement: Модуль пакета +`GET /batches` и `GET /batches/{id}` SHALL отдавать у пакета поле `module` — имя модуля, которому принадлежит тип первой +команды пакета (по описаниям модулей в реестре); пакет без команд или с типом, которого не знает ни один модуль, — +`module = null`. Фильтр `GET /batches?module=<имя>` SHALL отдавать только пакеты модуля; неизвестный модуль — 404. + +#### Scenario: Модуль у пакетов +- **WHEN** в очереди пакет «Автообновление» (`refresh`, автор `hub`) и пакет `wiki_sync` проекта `AWPCA`; `GET /batches` +- **THEN** у первого `module = "jira"`, у второго `module = "wiki"` + +#### Scenario: Фильтр по модулю +- **WHEN** `GET /batches?module=wiki&project=AWPCA` +- **THEN** только пакеты модуля wiki проекта `AWPCA` + +#### Scenario: Неизвестный модуль +- **WHEN** `GET /batches?module=nope` +- **THEN** 404 diff --git a/openspec/changes/web-modules/specs/web-app/spec.md b/openspec/changes/web-modules/specs/web-app/spec.md new file mode 100644 index 0000000..cfe8f3e --- /dev/null +++ b/openspec/changes/web-modules/specs/web-app/spec.md @@ -0,0 +1,299 @@ +## MODIFIED Requirements + +### Requirement: Оболочка и адреса +Приложение SHALL открываться под префиксом `/app/`. Главная страница — «Обзор» ядра (`/app/?project=<метка>`, +требование «Обзор проекта»). Страницы модулей SHALL лежать под префиксом имени модуля: модуль jira — `/app/jira/board` +(доска связей), `/app/jira/issues` (список задач), `/app/jira/feed` (лента изменений, требование «Лента изменений») и +`/app/jira/issues/{key}` (карточка задачи — окно поверх страницы, требование «Карточка задачи»); модуль wiki — +`/app/wiki` (требование «Вики проекта»). Прежние адреса `/app/board`, `/app/issues`, `/app/issues/{key}`, `/app/feed` +SHALL перенаправлять на новые с сохранением строки запроса, без новой записи истории. Неизвестный путь — страница «не +найдено» со ссылками на «Обзор» и разделы модулей. + +Боковая панель SHALL быть закреплена у левого края окна на всю его высоту и MUST NOT прокручиваться вместе со +страницей; если её содержимое не помещается по высоте, прокручивается сама панель. Сверху вниз панель SHALL содержать: +название «SDLC Hub» и кнопку «Свернуть панель»; выпадающий список проектов («<метка> — <название>», текущий отмечен, в +конце «+ Новый проект…»; требование «Проект в приложении»); пункт «Обзор»; разделы модулей группами — заголовок группы +— название модуля («Jira», «Вики»), под ним ссылки разделов модуля со значками (текущий отмечен +`aria-current="page"`); внизу — блок состояния модулей (требование «Состояние и обновление модулей»), переключатель +темы (требование «Тема») и меню с действием «Сменить токен». Свёрнутая панель SHALL быть узкой полосой с теми же +действиями значками: «Развернуть панель», метка текущего проекта (полностью — в подсказке), «Обзор», разделы +(группы разделены линией), по значку состояния на модуль, тема, меню; названия — в подсказках и `aria-label`. Свёртка +SHALL храниться в `localStorage` под ключом `sdlc-hub-sidebar` (`collapsed`; без записи — развёрнута). + +На экранах уже 640 px панели в потоке нет: сверху SHALL быть закреплена строка с кнопкой «Меню», меткой текущего +проекта и сводной точкой состояния модулей (ошибка, если она есть у какого-нибудь модуля; иначе — пакет в работе; +иначе — все агенты на связи или нет), которая открывает панель. «Меню» открывает развёрнутую панель поверх страницы +модальным окном: фокус переходит в панель и остаётся в ней, страница под ней недоступна; панель закрывается выбором +раздела, клавишей Escape (если не открыто вложенное меню — тогда Escape закрывает только его) или щелчком мимо неё; фокус +возвращается на «Меню». Горизонтальной прокрутки страницы на любой ширине нет. + +Заголовок вкладки браузера SHALL быть «<название страницы> · <метка проекта> · SDLC Hub» (без проекта — без средней +части); название страницы даёт оболочка («Обзор») или модуль. Неожиданная ошибка отрисовки страницы SHALL показываться +сообщением с кнопкой «Перезагрузить», не пустым экраном. + +#### Scenario: Переход между разделами +- **WHEN** на доске в боковой панели выбран раздел «Задачи» группы «Jira» +- **THEN** адрес — `/app/jira/issues` с текущим проектом, показан список задач; «назад» возвращает на доску + +#### Scenario: Раздел «Лента» +- **WHEN** на доске в боковой панели выбран раздел «Лента» +- **THEN** адрес — `/app/jira/feed` с текущим проектом, показана лента изменений за сегодня; раздел отмечен в панели + +#### Scenario: Сменить токен +- **WHEN** в меню боковой панели выбрано «Сменить токен» +- **THEN** токен очищен, показан экран входа + +#### Scenario: Панель на месте при прокрутке +- **WHEN** доска прокручена до самого низа +- **THEN** боковая панель видна целиком на прежнем месте: разделы и блок состояния модулей доступны без прокрутки вверх + +#### Scenario: Свёрнутая панель +- **WHEN** нажата «Свернуть панель», страница перезагружена +- **THEN** панель свёрнута в полосу значков, доска шире; значок «Задачи» открывает список задач; видны два значка + состояния — Jira и Вики; «Развернуть панель» возвращает полную панель + +#### Scenario: Узкий экран +- **WHEN** ширина окна 390 px, нажата «Меню», в панели выбран раздел «Задачи» +- **THEN** панель открылась поверх страницы, фокус в панели; после выбора панель закрыта, фокус на «Меню», показан список + задач; горизонтальной прокрутки нет + +#### Scenario: Прежний адрес +- **WHEN** открыт `/app/issues/AURORA-1536?project=AWPCA` +- **THEN** адрес заменён на `/app/jira/issues/AURORA-1536?project=AWPCA`, карточка открыта поверх списка задач + +#### Scenario: Заголовок вкладки +- **WHEN** открыта страница «Вики» проекта `AWPCA` +- **THEN** заголовок вкладки — «Вики · AWPCA · SDLC Hub» + +### Requirement: Проект в приложении +«Обзор», доска, список задач, лента и вики SHALL показывать один проект — текущий. Текущий проект SHALL задаваться +параметром `project` в адресе страниц; без параметра — последний выбранный в этом браузере (`localStorage`), если он +есть в `GET /projects`, иначе первый из `GET /projects`; выбранный проект записывается в адрес без новой записи истории. +Проекта из адреса нет в `GET /projects` — страница SHALL показывать «проект <метка> не найден» и подсказку выбрать +проект в списке «Проект» боковой панели (на узком экране — в «Меню») вместо данных. Проектов нет — страницы SHALL +показывать приглашение создать проект («+ Новый проект…»). Переходы между страницами SHALL сохранять текущий проект; +смена проекта на странице, которой нет у нового проекта, открывает «Обзор». Карточка задачи +(`/app/jira/issues/{key}`) от проекта не зависит. + +Доска SHALL загружать `GET /board?project=<текущий>`. Раскладка, дорожки и фильтры доски в `localStorage` SHALL +храниться отдельно для каждого проекта; у проекта `AWPCA` — на прежних ключах (раскладка, сделанная до проектов, +сохраняется). Фильтр спринтов SHALL показываться, только если хотя бы у одной своей задачи снимка (`neighbor = false`) +есть спринт; иначе — «колонки «Спринт» нет — показаны все задачи». + +Форма «Новый проект» (пункт «+ Новый проект…» выпадающего списка) SHALL открываться окном поверх страницы: метка (с +подсказкой «метка задач в Jira и имя проекта в хабе») и название, затем поля каждого модуля в своей группе с +заголовком — названием модуля: «Jira» — номер доски Jira (необязательно; подсказка «scrum или kanban определится +сам»), «Вики» — корень вики (необязательно; подсказка «DOC-… или ссылка на страницу Eva»). Подсказка — текстом под +полем, не в placeholder. Пустое необязательное поле в запрос не попадает; числовое поле с нецелым значением — ошибка у +поля, запрос не уходит. «Создать» — `POST /projects` с автором `viewer`; ошибка хаба 409/422 — текст `detail` рядом с +кнопкой, окно остаётся открытым; успех — окно закрывается, текущим становится новый проект, каждый пакет из ответа +(`batches`) отслеживается своим модулем (требование «Состояние и обновление модулей»). + +#### Scenario: Первый вход после выкатки +- **WHEN** открыт `/app/jira/board` без `project`, в `localStorage` выбора нет, проекты `AWPCA` и `STOCKS_DRRK` +- **THEN** адрес — `/app/jira/board?project=AWPCA`, доска — `GET /board?project=AWPCA` с прежней раскладкой карточек + +#### Scenario: Переключение проекта +- **WHEN** на доске `AWPCA` в выпадающем списке выбран `STOCKS_DRRK`, затем открыт раздел «Задачи» +- **THEN** доска — `GET /board?project=STOCKS_DRRK` со своей раскладкой; адрес «Задач» — + `/app/jira/issues?project=STOCKS_DRRK` + +#### Scenario: Неизвестный проект в адресе +- **WHEN** открыт `/app/jira/issues?project=NOPE` +- **THEN** «проект NOPE не найден» и подсказка выбрать проект в боковой панели; запроса `GET /issues` нет + +#### Scenario: Создание проекта из формы +- **WHEN** в форме введены `STOCKS_DRRK`, «Запасы ДРРК», доска и корень вики пусто; нажато «Создать» +- **THEN** `POST /projects {label: "STOCKS_DRRK", name: "Запасы ДРРК", author: "viewer"}`; текущий проект — `STOCKS_DRRK`; + строка «Jira» блока состояния показывает «пакет #N выполняется» + +#### Scenario: Проект с корнем вики из формы +- **WHEN** в форме введены `STOCKS_DRRK`, «Запасы ДРРК», корень `DOC-001234`; нажато «Создать» +- **THEN** `POST /projects {label: "STOCKS_DRRK", name: "Запасы ДРРК", wiki_root: "DOC-001234", author: "viewer"}`; строка + «Jira» показывает пакет обновления, строка «Вики» — пакет синхронизации + +#### Scenario: Метка занята +- **WHEN** в форме введена метка `awpca`, хаб ответил 409 +- **THEN** окно открыто, рядом с «Создать» — текст ошибки хаба + +#### Scenario: Канбан-проект +- **WHEN** текущий проект — `STOCKS_DRRK`, ни у одной своей задачи снимка нет спринта (у соседа из `AWPCA` спринт есть) +- **THEN** вместо фильтра спринтов — «колонки «Спринт» нет — показаны все задачи» + +#### Scenario: Нецелый номер доски +- **WHEN** в поле «Номер доски Jira» введено `51.5`, нажато «Создать» +- **THEN** у поля — «нужно целое число», запроса `POST /projects` нет + +### Requirement: Вики проекта +Приложение SHALL иметь страницу «Вики» (`/app/wiki?project=<метка>`, раздел группы «Вики» боковой панели; текущий +проект — как у остальных страниц, «Проект в приложении»). Шапка страницы: заголовок «Вики», подзаголовок «<название +корня> · страниц · обновлено ДД.ММ ЧЧ:ММ» и действие «Обновить вики» (вторичная кнопка; требование «Состояние и +обновление модулей»). Страница SHALL показывать: +- дерево страниц проекта (`GET /wiki/tree`) слева, свёрнутое до первого уровня; узел раскрывается по клику; узел + выбранной страницы и его предки раскрыты; +- выбранную страницу справа (`GET /wiki/pages/{code}`): заголовок, дата синхронизации, ссылка «открыть в Eva», + Markdown с таблицами, без сырого HTML; без `page` в адресе — корневая страница; ссылки `page:<код>` открывают страницу + вики (код в адресе `&page=<код>`), ссылки `attachment:` скачивают вложение (запрос, опрос состояния, сохранение + файла браузером), состояние `failed` — текст ошибки рядом со ссылкой; +- на экранах уже 640 px выбранная страница — над деревом; +- поиск над деревом (`GET /wiki/search`): список результатов с фрагментами вместо дерева, пока строка поиска не пуста; + после очистки поиска раскрытие дерева прежнее; +- проекта без корня — форму «Корень вики (DOC-… или ссылка)» с кнопкой «Привязать» (`PATCH /projects/{label}`); 422 — + текст ошибки у поля; +- корень есть, синхронизаций нет — «вики ещё не синхронизирована» и действие «Обновить вики»; +- последняя синхронизация с ошибкой — плашку с текстом ошибки над страницей. + +#### Scenario: Открыть страницу по ссылке +- **WHEN** на странице DOC-3 нажата ссылка `page:DOC-2` +- **THEN** адрес `/app/wiki?project=AWPCA&page=DOC-2`, справа DOC-2, в дереве DOC-2 выделен + +#### Scenario: Проект без корня +- **WHEN** открыта `/app/wiki?project=sfm_kedr`, корня нет; введено `DOC-001234`, нажато «Привязать» +- **THEN** `PATCH /projects/sfm_kedr {wiki_root: "DOC-001234"}`; строка «Вики» блока состояния показывает ход пакета + синхронизации + +#### Scenario: Поиск +- **WHEN** в строке поиска «согласов» +- **THEN** вместо дерева — результаты `GET /wiki/search?project=AWPCA&q=согласов`; очистка строки возвращает дерево + +#### Scenario: Без выбранной страницы +- **WHEN** открыта `/app/wiki?project=AWPCA` без `page` +- **THEN** справа корневая страница проекта, в дереве она выделена + +## REMOVED Requirements + +### Requirement: Обновление из Jira +**Reason**: обновление и ход пакетов стали общими для модулей. +**Migration**: требование «Состояние и обновление модулей»; поведение «Обновить из Jira» (одна кнопка на приложение, +`POST /refresh {full: true, author: "viewer"}`, один опрос, подхват, остановка, перечитывание) сохраняется в нём для +модуля jira. + +## ADDED Requirements + +### Requirement: Состояние и обновление модулей +Каждый веб-модуль SHALL описывать своё состояние данными, а рисовать их SHALL ядро. Описание модуля: название для +показа («Jira», «Вики»); агент (имя модуля в `GET /agents`) или его отсутствие; свежесть данных для текущего проекта — +время, охват («все проекты» или «проект») и ошибка последнего обновления; действие обновления (название, охват, +постановка пакета) или его отсутствие; правило, какие пакеты принадлежат модулю; данные, которые надо перечитать после +пакета модуля; есть ли у модуля данные для проекта (у вики без корня — нет). + +Ядро SHALL: +- опрашивать `GET /agents` при входе и раз в 60 с — один опрос на приложение; агент модуля: «на связи», «офлайн» или + «обновите агента» (есть `warning` или `update_hint`, текст — в подсказке); +- вести один трекер пакетов на приложение: на модуль — не больше одного отслеживаемого пакета; пакет подхватывается при + входе, раз в 60 с и после действия обновления, если он не завершён, принадлежит модулю (поле `module` в `GET + /batches`) и текущему проекту (для модулей с охватом «проект») — любого автора, в том числе автообновления хаба и + MCP; опрос `GET /batches/{id}` каждые 3 с до терминального статуса; 4xx (404 — «пакета нет в хабе») и 5 ошибок «хаб + не отвечает» подряд — опрос остановлен с сообщением; по завершении — перечитать данные модуля и список проектов; +- ответ 409 на постановку пакета — отслеживать уже идущий пакет модуля, без сообщения об ошибке; +- агент модуля офлайн при обновлении — пакет всё равно ставится, событие «агент офлайн — пакет выполнится, когда он + выйдет на связь; пакет #N в очереди…». + +Блок состояния в развёрнутой боковой панели — по строке на модуль, у которого есть данные для проекта: название модуля, +точка агента (цвет и текст «агент на связи» / «агент офлайн» / «обновите агента» — не только цвет), кнопка-значок +обновления с подписью действия в подсказке и `aria-label` («Обновить из Jira — все проекты», «Обновить вики — проект +AWPCA»); под ними — «данные на ДД.ММ ЧЧ:ММ» с охватом («все проекты» / «проект»), «обновления ещё не было» или ошибка +последнего обновления; под ними — одно текущее событие пакета модуля: в очереди / выполняется (с оставшимся временем, +если хаб его дал, и пометками «агент офлайн», «хаб не отвечает, повтор»), выполнен (исчезает через 10 с), не выполнен +с текстом ошибки, отменён, опрос остановлен с причиной. Кнопка обновления модуля недоступна (с признаком загрузки), пока +запрос постановки в пути или выполняется пакет действия обновления модуля (у jira — пакет с первой командой `refresh`, у +wiki — `wiki_sync`); прочие пакеты модуля показываются, но обновление не блокируют: нажатие ставит пакет обновления, и +трекер переключается на него. Свёрнутая панель — по значку модуля с точкой агента, подробности в +подсказке, нажатие — обновление. На узком экране — сводная точка в верхней строке (требование «Оболочка и адреса»), +событие пакета — строкой под верхней строкой. + +Модуль jira: агент `jira`; свежесть — `jira_synced_at` из `GET /projects`, охват «все проекты»; действие «Обновить из +Jira» — `POST /refresh {full: true, author: "viewer"}`; после пакета перечитываются снимки досок, списки задач, лента и +открытая карточка; доска, показывающая снимок из файла, его не заменяет. Модуль wiki: агент `wiki`; свежесть — +`wiki_synced_at`, `wiki_sync_error` из `GET /projects`, охват «проект»; данные для проекта есть, только если есть +`wiki_root`; действие «Обновить вики» — `POST /wiki/sync {project, author: "viewer"}`; после пакета перечитываются дерево, +страницы и поиск. Блок состояния MUST NOT загружать снимок доски или `GET /wiki/status`. + +Страница модуля с действием обновления SHALL дублировать его вторичной кнопкой в шапке страницы; основной (primary) +кнопки обновления в приложении нет. + +#### Scenario: Обновление со списка задач +- **WHEN** открыт `/app/jira/issues`, нажато «Обновить из Jira» в строке «Jira», пакет выполнен +- **THEN** в хабе один пакет `refresh` автора `viewer`; список задач перезапрошен; строка «Jira» показывала «пакет #N + выполняется», затем «пакет #N выполнен»; строка «Вики» не изменилась + +#### Scenario: Один прогон на модуль +- **WHEN** нажата «Обновить из Jira» и, пока пакет выполняется, выполнен переход между разделами и нажата «Обновить из + Jira» в шапке доски +- **THEN** кнопки «Обновить из Jira» недоступны, в хабе один пакет, его опрашивает один опрос + +#### Scenario: Опрос получил 404 +- **WHEN** опрос пакета вики получил 404 +- **THEN** опрос остановлен, строка «Вики» показывает «пакета нет в хабе», «Обновить вики» снова доступна + +#### Scenario: Автообновление хаба +- **WHEN** хаб поставил пакет «Автообновление» (автор `hub`, модуль `jira`), приложение открыто на странице «Вики» +- **THEN** не позже чем через 60 с строка «Jira» показывает «пакет #N выполняется», по завершении — новое «данные на …» + +#### Scenario: Пакет другого проекта +- **WHEN** идёт пакет `wiki_sync` проекта `STOCKS_DRRK`, текущий проект — `AWPCA` +- **THEN** строка «Вики» его не показывает, «Обновить вики» доступна + +#### Scenario: Пакет с другими командами +- **WHEN** незавершённый пакет модуля jira автора `viewer` состоит из команд `ensure_fields` +- **THEN** строка «Jira» показывает «пакет #N выполняется», кнопка «Обновить из Jira» доступна; её нажатие ставит пакет + `refresh`, и строка показывает его + +#### Scenario: Агент офлайн +- **WHEN** агент `wiki` офлайн и нажато «Обновить вики» +- **THEN** пакет поставлен; строка «Вики» — точка с текстом «агент офлайн» и событие «агент офлайн — пакет выполнится, + когда он выйдет на связь; пакет #N в очереди…» + +#### Scenario: Синхронизация уже идёт +- **WHEN** нажато «Обновить вики», хаб ответил 409 +- **THEN** строка «Вики» показывает идущий пакет, сообщения об ошибке нет + +#### Scenario: Проект без корня вики +- **WHEN** текущий проект `sfm_kedr` без корня вики +- **THEN** в блоке состояния только строка «Jira»; на свёрнутой панели — один значок состояния + +#### Scenario: Агенту нужно обновление +- **WHEN** `GET /agents` отдаёт агента `jira` с `warning` +- **THEN** строка «Jira» — точка с текстом «обновите агента», текст `warning` в подсказке + +### Requirement: Обзор проекта +Страница ядра «Обзор» (`/app/?project=<метка>`, пункт «Обзор» боковой панели) SHALL показывать шапку «Обзор» с +названием проекта и под ней карточку на каждый модуль, у которого есть данные для проекта: название модуля, агент (с +версией из `GET /agents`), свежесть с охватом, текущее событие пакета, вторичная кнопка обновления и сводка, которую +даёт модуль. Сводка jira: число своих задач проекта, из них открытых и просроченных (по снимку доски проекта), ссылки +«Доска связей», «Задачи», «Лента». Сводка wiki: число страниц, поле поиска по вики (Enter открывает «Вики» с этим +поиском), ссылка «Вики». Модуль без данных для проекта SHALL показывать карточку с объяснением и действием модуля (у +вики — «Привязать корень вики» ведёт на страницу «Вики»). Под карточками — «Последние пакеты проекта»: до 10 из +`GET /batches?project=<метка>`: номер, модуль, название, автор, статус, время создания. + +#### Scenario: Главная +- **WHEN** открыт `/app/` без проекта, последний выбранный — `AWPCA` +- **THEN** адрес `/app/?project=AWPCA`, две карточки — «Jira» и «Вики», список последних пакетов + +#### Scenario: Поиск по вики с обзора +- **WHEN** на «Обзоре» `AWPCA` в поле поиска вики введено «согласов» и нажат Enter +- **THEN** открыта «Вики» с результатами поиска «согласов» + +#### Scenario: Проект без вики +- **WHEN** «Обзор» проекта `sfm_kedr` без корня вики +- **THEN** карточка «Вики» с объяснением и ссылкой «Привязать корень вики» + +### Requirement: Граница модулей в вебе +Ядро веба (`web/src/{shell,ui,api,projects,auth,styles}`) MUST NOT импортировать модули, кроме списка модулей +`modules/index.ts`, и MUST NOT содержать типов, справочников и терминов модулей (типы задач Jira, закрытые статусы, +словари видов работ и связей, «статус по факту»). Модуль MUST NOT импортировать другой модуль; общее (форматтеры дат +«ДД.ММ», «ДД.ММ ЧЧ:ММ», «ДД.ММ.ГГГГ» для дат не текущего года, скачивание файла) — в ядре. Имена модулей и адреса +разделов (`to`) SHALL быть уникальны. Имя класса CSS, объявленное в стилях модуля, MUST NOT объявляться в стилях +другого модуля или ядра. Даты в списке задач и карточке SHALL выводиться форматтерами ядра, не строкой ISO. + +#### Scenario: Пересечение классов +- **WHEN** в `modules/jira/**.css` и `modules/wiki/**.css` объявлен класс `.wiki` +- **THEN** тесты фронта падают с именем класса и файлами + +#### Scenario: Импорт чужого модуля +- **WHEN** файл в `modules/wiki/` импортирует из `modules/jira/` +- **THEN** тесты фронта падают с именем файла + +#### Scenario: Дубль адреса раздела +- **WHEN** два модуля объявляют раздел с одинаковым `to` +- **THEN** тесты фронта падают с адресом и модулями -- 2.47.2 From 52a6c93db8bca8c322d6bcd3d6fdee344e05fe8f Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 11:31:38 +0400 Subject: [PATCH 02/19] =?UTF-8?q?plan(web-modules):=20=D0=BF=D0=BB=D0=B0?= =?UTF-8?q?=D0=BD=20=D1=80=D0=B5=D0=B0=D0=BB=D0=B8=D0=B7=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D0=B8=20=D0=B2=D0=B5=D0=B1=D0=B0=20=D0=BF=D0=BE=D0=B4=20=D0=BC?= =?UTF-8?q?=D0=BE=D0=B4=D1=83=D0=BB=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../plans/2026-10-24-web-modules.md | 185 ++++++++++++++++++ 1 file changed, 185 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-24-web-modules.md diff --git a/docs/superpowers/plans/2026-10-24-web-modules.md b/docs/superpowers/plans/2026-10-24-web-modules.md new file mode 100644 index 0000000..6a6be1d --- /dev/null +++ b/docs/superpowers/plans/2026-10-24-web-modules.md @@ -0,0 +1,185 @@ +# Веб под модули — план реализации + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** оболочка веба рисует состояние модулей (агент, свежесть, обновление, пакет) по их описанию, навигация сгруппирована по модулям с главной страницей «Обзор», адреса модулей с префиксом модуля, граница ядра и модулей проверяется тестами, дефекты страниц из аудита исправлены. + +**Architecture:** контракт `HubModule` получает `title`, `status: ModuleStatus` (данные) и `Summary`; ядро (`web/src/shell/activity/`) держит один опрос `GET /agents` и один трекер пакетов на все модули (обобщение `RefreshProvider` модуля jira); блок состояния, «Обзор», верхняя строка узкого экрана — компоненты ядра. Бэкенд добавляет `jira_synced_at` в проект, `module` в пакет и `batches` в ответы `POST/PATCH /projects`. + +**Tech Stack:** React 19 + TypeScript + Vite, TanStack Query, react-router, Vitest + Testing Library, Playwright; Python 3.13 (uv, pytest). + +**Spec:** `openspec/changes/web-modules/` (proposal.md, design.md, specs/web-app, specs/hub-api) — issue jira-hub#98. Аудит и решения — design.md. + +## Global Constraints + +- **Предусловие:** ветка перебазирована на `main` после слияния sdlc-rename (#97): пакет `src/sdlc_hub/`, бренд «SDLC Hub», ключи `sdlc-hub-*`, `readMigrated` в `web/src/api/legacyStorage.ts`. Без этого план не начинать. +- Адреса: «Обзор» `/` (под `/app`), jira — `/jira/board`, `/jira/issues`, `/jira/issues/:key`, `/jira/feed`; wiki — `/wiki`; прежние `/board`, `/issues`, `/issues/:key`, `/feed` — перенаправление с сохранением `search`, `replace`. +- Тексты (точно): названия модулей «Jira», «Вики»; «Обзор»; агент — «агент на связи» / «агент офлайн» / «обновите агента»; свежесть — «данные на ДД.ММ ЧЧ:ММ · все проекты» / «данные на ДД.ММ ЧЧ:ММ · проект», «обновления ещё не было»; действия — «Обновить из Jira», «Обновить вики»; подсказки значков — «Обновить из Jira — все проекты», «Обновить вики — проект <метка>»; заголовок вкладки «<страница> · <метка> · SDLC Hub»; «Последние пакеты проекта»; «Команды хаба»; «Привязать корень вики»; «нужно целое число». +- События пакета — прежние тексты `refreshText` (`пакет #N в очереди/выполняется…`, `выполнен`, `не выполнен: …`, `отменён`, `…: <причина> — опрос остановлен, «<действие>» снова доступна`, `хаб не отвечает — пакет не поставлен`); `POLL_MS = 3000`, `MAX_POLL_ERRORS = 5`, `DONE_VISIBLE_MS = 10_000`; подхват и `GET /agents` — раз в `60_000` мс. +- Primary-кнопок обновления нет: значок в строке модуля, `variant="secondary"` в шапках страниц. +- Цвет не единственный признак: точка агента и просрочка — с текстом (`title`/скрытый текст). +- AURA (скилл `aura-design`): цвета только из `styles/tokens.css`, компоненты из `web/src/ui/`. +- Зелёный — весь набор: `uv run pytest -q`, `cd web && npm run lint && npm test`; перед финальным ревью ещё `cd web && npm run e2e`. +- Живой хаб (:8790) и его данные не трогать; e2e — только на тестовом хабе (`web/scripts/test-hub.sh`, :8791). +- Язык комментариев, текстов, коммитов — русский; коммиты заканчиваются `Co-Authored-By: Claude Opus 5.5 `. + +## Review Focus + +1. Пакет, поставленный из MCP или автообновлением, во время открытой страницы: строка модуля должна его показать не позже чем через 60 с и не блокировать кнопку обновления — Task 3, `picks up batch of any author` и `foreign batch does not block refresh`. +2. Смена проекта во время идущего пакета вики: строка «Вики» нового проекта не показывает чужой пакет, опрос старого не течёт в новую строку — Task 3, `project switch drops project-scoped batch`. +3. Проект без корня вики на всех поверхностях (панель развёрнута/свёрнута, телефон, «Обзор»): нет пустой строки «Вики», нет запроса `/wiki/status` из оболочки — Task 4 `no wiki row without root`, Task 5 `overview card without root`. +4. Прежний адрес карточки из истории браузера (`/app/issues/KEY` без проекта): перенаправление и окно поверх списка, закрытие окна возвращает на `/jira/issues` с текущим проектом — Task 4, `legacy card address`. +5. Пересечение классов CSS, которое уже ломало «Вики»: тест-страж должен падать на `div.wiki`-подобном конфликте и на дубле класса между модулем и ядром — Task 2, `css classes do not collide`. + +--- + +### Task 1: API для блока состояния + +**Files:** +- Modify: `src/sdlc_hub/modules/jira/project_fields.py` (`view`: `jira_synced_at`), `src/sdlc_hub/core/queue.py` (`batch_view`: `module`; `list_batches(conn, limit=20, project=None, module=None)`), `src/sdlc_hub/core/api.py` (`h_batches_get`: `?module=`, неизвестный — 404 `not_found`; `POST /projects` и `PATCH /projects/{label}`: `batches`; `GET /` и `GET /links` → `Location: /app/`) +- Test: `tests/test_project_fields.py`, `tests/test_queue_core.py`, `tests/test_api.py`, `tests/test_web_app.py`, `tests/test_wiki_projects.py` + +**Interfaces:** +- Produces: `GET /projects[].jira_synced_at: str | null` — `finished_at` последнего `sync_run` полного обновления (тот же запрос, что `last_sync` в `modules/jira/mirror.py` снимка доски — вынести в функцию `last_full_sync(conn) -> dict | None` модуля jira и использовать в обоих местах); `batch_view(...)["module"]: str | None` — `registry.module_of(<тип первой команды по seq>).name`; `POST /projects` → `{project, batch_id, batches: [{"module": str, "id": int}]}`; `PATCH` → `{project, batch_ids, batches}`. + +- [ ] **Step 1: Тесты** + - `test_projects_jira_synced_at`: до полного обновления `GET /projects` → у проекта `jira_synced_at is None`; после завершённого полного `refresh` с `sync_run.finished_at = "2026-10-24T11:07:00"` → это значение у каждого проекта. + - `test_batch_module`: пакет `refresh` → `module == "jira"`; пакет `wiki_sync` → `"wiki"`; пакет без команд → `None` (в `list_batches` и `batch_view`). + - `test_batches_module_filter`: `GET /batches?module=wiki&project=AWPCA` → только пакеты wiki проекта; `GET /batches?module=nope` → 404. + - `test_create_project_batches`: `POST /projects {label, name, wiki_root: "DOC-001234"}` → `batches == [{"module": "jira", "id": }, {"module": "wiki", "id": }]`, `batch_id == `; `PATCH /projects/AWPCA {wiki_root}` → `batches == [{"module": "wiki", "id": batch_ids[0]}]`. + - `test_root_redirect` (в `test_web_app.py`, заменить прежний): `GET /` и `GET /links` → 302, `Location: /app/`. +- [ ] **Step 2:** `uv run pytest tests/test_project_fields.py tests/test_queue_core.py tests/test_api.py tests/test_web_app.py tests/test_wiki_projects.py -q` — FAIL. +- [ ] **Step 3:** реализация по Interfaces. В ядре (`queue.py`, `api.py`) — без названий модулей (граница `tests/test_registry.py`): модуль берётся из `registry.module_of`. +- [ ] **Step 4:** `uv run pytest -q` — весь набор PASS (в том числе `test_registry.py`, эталоны MCP: `batch_id` в MCP не меняется). +- [ ] **Step 5: Commit** — `feat(web-modules): API — свежесть Jira в проекте, модуль пакета, пакеты нового проекта по модулям`. + +### Task 2: Граница ядра и модулей в вебе + +**Files:** +- Create: `web/src/ui/format.ts` (+ `format.test.ts`), `web/src/modules/jira/api/types.ts`, `web/src/boundary.test.ts` +- Move: `web/src/api/closed.ts` (+ тест) → `web/src/modules/jira/api/closed.ts` +- Modify: `web/src/api/types.ts` (остаются `Batch` (+ `module: string | null`), `BatchCommand`, `BatchStatus`, `AgentView`, `CreatedBatch`, `Project`, `CreatedProject`, `UpdatedProject`, `HubSettings` без словарей jira; `fact_status` удалён везде, в том числе `web/src/test/issueFixtures.ts` и комментарий `modules/jira/issues/card.ts`), импорты типов в модулях; `modules/jira/board/model/util.ts` (`fmtStamp` переезжает в `ui/format.ts`), `modules/wiki/PageView.tsx`, `modules/wiki/SyncStatus.tsx`; `modules/jira/issues/wiki/wiki.css` и разметка, использующая `className="wiki"` в модуле jira → `jira-markup`; `IssueTable.tsx` и `IssueCard.tsx` (даты через `fmtDate`); `web/src/modules/index.ts` или `modules.test.tsx` (уникальность) + +**Interfaces:** +- Produces: `fmtStamp(iso: string | null): string` — «ДД.ММ ЧЧ:ММ» (как сейчас в `board/model/util.ts`); `fmtDate(iso: string | null, now?: Date): string` — «ДД.ММ», год не текущий — «ДД.ММ.ГГГГ», `null` — «—»; `AgentView` = поля `GET /agents`: `{module: string; online: boolean; last_seen_at: string | null; agent_version: number | null; warning: string | null; update_hint: string | null; current_command_id: number | null; release: {version: number} | null}`. + +- [ ] **Step 1: Тесты** + - `format.test.ts`: `fmtDate("2026-10-19", new Date("2026-10-24"))` → `"19.10"`; `fmtDate("2025-12-31", new Date("2026-10-24"))` → `"31.12.2025"`; `fmtDate(null)` → `"—"`; `fmtStamp("2026-10-24T11:07:00")` → `"24.10 11:07"`. + - `boundary.test.ts` (читает файлы через `import.meta.glob(..., { query: "?raw", eager: true })`): + - `core does not import modules`: файлы `src/{shell,ui,api,projects,auth,styles}/**/*.{ts,tsx}` (кроме тестов) не импортируют `modules/` иначе как `modules/index.ts` и `modules/types.ts`; + - `module does not import other module`: файл `modules//**` не импортирует `modules//`; + - `css classes do not collide`: множество имён классов (`/\.([a-zA-Z_][\w-]*)/g` по селекторам, без значений свойств) в `modules//**/*.css` не пересекается с `modules//**/*.css` и с `src/{shell,ui,styles,projects,auth}/**/*.css`; сообщение — имя класса и файлы; + - `core has no module terms`: в файлах ядра нет `fact_status`, `closed_statuses`, `kind_labels`, `link_types`, `BoardIssue`, `IssueRow`. + - `modules.test.tsx`: `names unique` и `nav paths unique` (T7.6) — сообщение с дублем; `NotFound` — ключ ссылки уникален при дубле (T7.5). +- [ ] **Step 2:** `cd web && npx vitest run src/ui/format.test.ts src/boundary.test.ts src/modules/modules.test.tsx` — FAIL (пересечение `.wiki`, импорт wiki → jira, термины в `api/types.ts`). +- [ ] **Step 3:** перенос типов и `closed.ts`, `ui/format.ts`, `jira-markup`, даты в таблице и карточке, удаление `fact_status`. +- [ ] **Step 4:** `cd web && npm run lint && npm test` — PASS; на странице «Вики» тестового хаба заголовок h1 — 20 px (проверить в e2e Task 8). +- [ ] **Step 5: Commit** — `refactor(web-modules): граница ядра и модулей в вебе, форматтеры дат в ядре, без fact_status`. + +### Task 3: Контракт состояния модуля и трекер пакетов в ядре + +**Files:** +- Create: `web/src/shell/activity/ActivityProvider.tsx`, `web/src/shell/activity/activity.ts` (контекст и хуки), `web/src/shell/activity/events.ts` (перенос `refreshStatus.ts`: `ModuleEvent`, `eventText(e, actionLabel)`, константы), `web/src/shell/activity/activity.test.tsx` (перенос и обобщение `modules/jira/refresh/refresh.test.tsx`), `web/src/modules/jira/status.ts`, `web/src/modules/wiki/status.ts` +- Modify: `web/src/modules/types.ts` (`ModuleStatus`, `HubModule.title`, `status`, `Summary`; удалить `SidebarStatus`, `TopbarNotice`, `useOnProjectCreated`), `web/src/modules/index.ts`, `web/src/modules/jira/index.tsx`, `web/src/modules/wiki/index.tsx`, `web/src/App.tsx` (провайдер ядра), `web/src/modules/wiki/api/queries.ts` (`useWikiSync` не нужен оболочке; страница вики берёт действие из трекера) +- Delete: `web/src/modules/jira/refresh/` (`RefreshProvider.tsx`, `refresh.ts`, `refreshStatus.ts`, `DataStatus.tsx`, `SidebarStatus.tsx`, их тесты) после переноса тестов; `web/src/modules/wiki/SyncStatus.tsx` (+ тест) + +**Interfaces:** +- Consumes: `Batch.module`, `AgentView` (Task 2), `GET /batches?module=&project=&limit=`, `jira_synced_at` (Task 1). +- Produces: + - `ModuleStatus` — как в design.md («Контракт веб-модуля»): `agent?: string; scope: "all" | "project"; freshness(p: Project): {available: boolean; at: string | null; error: string | null}; refresh?: {label: string; start(project: string): Promise<{id: number; estimate_seconds?: number}>; isOwn(b: Batch): boolean}; invalidate(qc: QueryClient, project: string | null): void`. + - `useModuleActivity(name: string): {agent: AgentView | null; event: ModuleEvent | null; busy: boolean; running: Batch | null; start(): Promise; track(id: number): void}` — `busy`: постановка в пути или отслеживаемый пакет удовлетворяет `refresh.isOwn`. + - `useAgents(): AgentView[]`; `useActivitySummary(): "error" | "busy" | "online" | "offline"` (для сводной точки). + - `ActivityProvider({children, project, pollMs?})` — `project` — текущая метка (из `ProjectContext`). + - jira: `status = {agent: "jira", scope: "all", freshness: p => ({available: true, at: p.jira_synced_at ?? null, error: null}), refresh: {label: "Обновить из Jira", start: () => api.post("/refresh", {full: true, author: "viewer"}), isOwn: b => b.commands?.[0]?.type === "refresh"}, invalidate: board-все, issues, issue, changes}`; wiki: `{agent: "wiki", scope: "project", freshness: p => ({available: !!p.wiki_root, at: p.wiki_synced_at ?? null, error: p.wiki_sync_error ?? null}), refresh: {label: "Обновить вики", start: project => api.post("/wiki/sync", {project, author: "viewer"}), isOwn: b => b.commands?.[0]?.type === "wiki_sync"}, invalidate: ["wiki", project] tree/page/search}`. +- Правила трекера: подхват — при монтировании, при смене проекта, раз в 60 с и после `start`: `GET /batches?module=&limit=20` (+`&project=<метка>` для `scope: "project"`), первый незавершённый (`queued`/`running`, самый старый) — если ничего не отслеживается; при смене проекта отслеживаемый пакет модуля с `scope: "project"` сбрасывается без события. `start`: агент офлайн по `useAgents()` → `agentOffline: true` в событии; ответ 409 (`ApiError.status === 409`) — сразу подхват без события ошибки. Если отслеживается чужой пакет (не `isOwn`), `start` ставит пакет и переключает отслеживание на него. По завершении пакета — событие, `module.status.invalidate(qc, project)`, перечитывание `["projects"]`. + +- [ ] **Step 1: Тесты** `activity.test.tsx` (мок `fetch` как в прежнем `refresh.test.tsx`, фейковые таймеры) — перенести все сценарии прежнего файла на модуль jira (`M-2`, `M-3`, `M-4`, `T10.2`, `T4.1`, 401, `batch-done` гаснет, переход между страницами), заменив «подхват viewer+refresh» новыми: + - `picks up batch of any author`: `GET /batches?module=jira` отдаёт пакет автора `hub` в `running` → `event.kind === "batch"`, опрос `GET /batches/{id}`; + - `foreign batch does not block refresh`: отслеживается пакет jira с `commands[0].type = "ensure_fields"` → `busy === false`; `start()` → один `POST /refresh`, `event.id` — новый пакет; + - `project switch drops project-scoped batch`: отслеживается `wiki_sync` проекта `AWPCA`, `project` → `STOCKS_DRRK` → `event === null`, опрос пакета AWPCA остановлен; jira-пакет не сброшен; + - `wiki 409 tracks running batch`: `POST /wiki/sync` → 409 → подхват идущего пакета, события ошибки нет; + - `agent offline from /agents`: `GET /agents` → wiki `online: false`; `start()` для wiki → событие с `agentOffline: true`; + - `invalidates module data only`: завершение пакета wiki инвалидирует `["wiki", "AWPCA", …]` и `["projects"]`, не `["board"]`; + - `one agents poll`: два модуля → один запрос `GET /agents` за интервал. +- [ ] **Step 2:** `cd web && npx vitest run src/shell/activity` — FAIL. +- [ ] **Step 3:** реализация. Состояние — на модуль (`Record`), опросы пакетов — `useQueries` по активным; логика остановки — перенос из `RefreshProvider` без изменений. +- [ ] **Step 4:** `cd web && npm run lint && npm test` — PASS (временные заглушки оболочки, если Task 4 ещё не сделан: `Sidebar` может рендерить старый блок через `useModuleActivity` — главное, чтобы набор был зелёным). +- [ ] **Step 5: Commit** — `feat(web-modules): состояние модуля в контракте, трекер пакетов и агенты в ядре`. + +### Task 4: Оболочка — группы, блок состояния, узкий экран, адреса + +**Files:** +- Create: `web/src/shell/ModuleStatusBlock.tsx` (+ тест), `web/src/shell/useDocumentTitle.ts` +- Modify: `web/src/shell/Sidebar.tsx` (+ `Sidebar.test.tsx`), `web/src/shell/Layout.tsx`, `web/src/shell/NotFound.tsx`, `web/src/shell/shell.css`, `web/src/modules/jira/index.tsx` (пути `/jira/…`, перенаправления прежних путей), `web/src/modules/jira/issues/modal.ts` и `overlay.tsx` (`/jira/issues`, закрытие окна — с текущим проектом), все ссылки на `/board`, `/issues`, `/feed` внутри модулей +- Test: `web/src/shell/*.test.tsx`, `web/src/projects/projects.test.tsx` + +**Interfaces:** +- Consumes: `useModuleActivity`, `useAgents`, `useActivitySummary` (Task 3); `HubModule.title`, `status`. +- Produces: `NavItem.title?: string` (название страницы для вкладки; по умолчанию `label`); `useDocumentTitle(page: string | null, project: string | null)`; `ModuleStatusBlock({project, variant: "expanded" | "collapsed"})`; оболочка: пункт «Обзор» (`to: "/"`) — первым, затем группы `MODULES` с заголовком `title`; смена проекта вне раздела → `/`. + +- [ ] **Step 1: Тесты** + - `ModuleStatusBlock.test.tsx`: две строки «Jira» и «Вики» с точкой и текстом агента («агент на связи»/«агент офлайн»/«обновите агента», `warning` в `title`); «данные на 24.10 11:07 · все проекты» и «данные на 24.10 11:05 · проект»; значок с `aria-label` «Обновить из Jira — все проекты» и «Обновить вики — проект AWPCA»; `jira_synced_at: null` → «обновления ещё не было»; `wiki_sync_error: "403 Forbidden"` → текст ошибки в строке «Вики» (тон ошибки, с текстом); `no wiki row without root` — проект без `wiki_root` → одна строка, запросов `/wiki/status` и `/board` нет; `collapsed` — по значку на модуль, подробности в `title`. + - `Sidebar.test.tsx`: заголовки групп «Jira», «Вики»; «Обзор» первым; на 390 px верхняя строка — «Меню», метка проекта, сводная точка (`aria-label` с состоянием); `drawer is modal` — фокус в панели, Escape во вложенном меню закрывает только меню (M-1 #73), Escape после — панель, фокус на «Меню»; метка проекта свёрнутой панели целиком в `title` (M-4). + - `Layout`/`projects.test.tsx`: `legacy card address` — `/issues/AURORA-1?project=AWPCA` → `/jira/issues/AURORA-1?project=AWPCA`, окно поверх списка, закрытие → `/jira/issues?project=AWPCA`; `/board?project=X` → `/jira/board?project=X`; смена проекта на «Вики» у проекта без вики → остаётся «Вики» (страница с формой привязки), на неизвестном адресе → `/`; заголовок вкладки «Вики · AWPCA · SDLC Hub», «Обзор · AWPCA · SDLC Hub». +- [ ] **Step 2:** `cd web && npx vitest run src/shell src/projects` — FAIL. +- [ ] **Step 3:** реализация. Панель прокручивается сама, если не влезает (M-5 #73); удалить мёртвый `--topbar-h` (M-6), устаревшие комментарии про верхнюю панель (M-7 #73, С19 аудита); событие пакета на узком экране — строкой под верхней строкой (замена `TopbarNotice`), на развёрнутой панели — в строке модуля; `role="status"` — постоянный контейнер (T10.1 #94). +- [ ] **Step 4:** `cd web && npm run lint && npm test` — PASS. +- [ ] **Step 5: Commit** — `feat(web-modules): боковая панель по модулям, блок состояния, узкий экран, адреса модулей`. + +### Task 5: Страница «Обзор» + +**Files:** +- Create: `web/src/shell/OverviewPage.tsx` (+ `OverviewPage.test.tsx`), `web/src/modules/jira/Summary.tsx`, `web/src/modules/wiki/Summary.tsx` +- Modify: `web/src/shell/pages.tsx` (маршрут `/` → «Обзор» под `ProjectGate`), `web/src/modules/jira/index.tsx`, `web/src/modules/wiki/index.tsx` (`Summary`), `web/src/api/queries.ts` (`useRecentBatches(project)` → `GET /batches?project=&limit=10`) + +**Interfaces:** +- Consumes: `ModuleStatusBlock` нет — карточка модуля «Обзора» рисует ядро из `useModuleActivity` + `status.freshness` + `useAgents` (версия `agent_version`), кнопка `variant="secondary"`; `Summary({project: Project})`. +- Produces: jira `Summary`: « задач · <открытых> открытых · <просроченных> просрочено» (по `useBoard(project)`: свои задачи `neighbor = false`; открытые — не `closed` по `modules/jira/api/closed.ts`; просроченные — открытые с `duedate` < сегодня), ссылки «Доска связей», «Задачи», «Лента»; wiki `Summary`: « страниц», поле поиска (Enter → `/wiki?project=<метка>&q=<строка>`; страница «Вики» читает `q` в поле поиска), ссылка «Вики»; без корня — объяснение и ссылка «Привязать корень вики» на `/wiki?project=<метка>`. + +- [ ] **Step 1: Тесты** `OverviewPage.test.tsx`: `/` с проектом `AWPCA` (корень есть) — две карточки «Jira», «Вики» с агентом и версией («v14», «v1»), свежестью, кнопками «Обновить из Jira», «Обновить вики» (обе `secondary`); `overview card without root` — `sfm_kedr` → карточка «Вики» с «Привязать корень вики»; «Последние пакеты проекта» — строки `#`, модуль, название, автор, статус, время; поиск по вики Enter → адрес `/wiki?project=AWPCA&q=согласов`, на «Вики» строка поиска заполнена и показаны результаты. +- [ ] **Step 2:** `cd web && npx vitest run src/shell/OverviewPage.test.tsx` — FAIL. +- [ ] **Step 3:** реализация (сетка карточек: 2 колонки ≥ 1024 px, 1 колонка уже; компоненты `Panel`, `PageHeader`, `Button`). +- [ ] **Step 4:** `cd web && npm run lint && npm test` — PASS. +- [ ] **Step 5: Commit** — `feat(web-modules): страница «Обзор» со сводками модулей и последними пакетами`. + +### Task 6: Окно «Новый проект» по модулям + +**Files:** +- Modify: `web/src/projects/NewProjectDialog.tsx` (+ тест), `web/src/projects/projects.css`, `web/src/api/queries.ts` (`useCreateProject`, `useUpdateProject` — тип ответа с `batches`), `web/src/api/types.ts` (`CreatedProject.batches`, `UpdatedProject.batches: {module: string; id: number}[]`), `web/src/shell/Layout.tsx` (`onCreated` → `track` каждого пакета своим модулем), `web/src/modules/wiki/RootForm.tsx` (пакет из `batches` → `track` модуля wiki) + +**Interfaces:** +- Consumes: `useModuleActivity(name).track` (Task 3), `HubModule.title`, `projectFields`. + +- [ ] **Step 1: Тесты** `NewProjectDialog.test.tsx`: группы `fieldset` с `legend` «Jira» и «Вики»; подсказки — текст под полем (`aria-describedby`), `placeholder` пустой; подсказка метки «метка задач в Jira и имя проекта в хабе»; `51.5` в «Номер доски Jira» → «нужно целое число», запроса нет (T4.1); ответ `batches` с двумя пакетами → `track` jira и `track` wiki вызваны со своими id (M-14, T5.3 #68); `setExtra` — функциональная форма (T4.2: два быстрых ввода в разные поля не теряют первое); `FIELDS` собирается при отрисовке, не при импорте (T4.5); `useUpdateProject` и показ 422 у поля корня — тесты (T4.4). +- [ ] **Step 2:** `cd web && npx vitest run src/projects src/modules/wiki` — FAIL. +- [ ] **Step 3:** реализация. +- [ ] **Step 4:** `cd web && npm run lint && npm test` — PASS. +- [ ] **Step 5: Commit** — `feat(web-modules): «Новый проект» — поля по модулям, подсказки, пакеты каждому модулю`. + +### Task 7: Страницы модулей — исправления по аудиту + +**Files:** +- Modify: `web/src/modules/wiki/WikiPage.tsx`, `Tree.tsx`, `Search.tsx`, `wiki.css` (+ тесты); `web/src/modules/jira/board/components/BoardHeader.tsx`, `web/src/modules/jira/issues/IssuesPage.tsx`, `IssueTable.tsx`, `issues.css`, `IssueCard.tsx`, `web/src/modules/jira/feed/FeedPage.tsx`; `web/src/projects/ProjectGate.tsx`; `web/src/styles/` (глобальное `scrollbar-gutter: stable` для прокручиваемой области страницы) + +- [ ] **Step 1: Тесты** + - «Вики»: шапка — h1 «Вики», подзаголовок « · страниц · обновлено ДД.ММ ЧЧ:ММ», кнопка «Обновить вики» `secondary` из `useModuleActivity("wiki")`; без `page` — открыт корень и выделен в дереве; узлы-предки выбранной страницы раскрыты, после очистки поиска раскрытие прежнее (T10.2, T10.6); `aria-setsize`/`aria-posinset` у узлов (T10.2); порядок заголовков h1 → h2 (T10.3); на 390 px (`matchMedia` мок) страница выше дерева; параметр `q` заполняет поиск. + - Доска, «Задачи», «Лента»: в шапке вторичная кнопка «Обновить из Jira» (`useModuleActivity("jira")`), нет primary; под шапкой доски нет пустого блока `.board-src` без содержимого (N-1), мёртвые пропсы `label`/`title` удалены (N-2 #68). + - «Задачи»: просроченный срок — с текстом «просрочено» для экранного чтения (`visually-hidden`) и `title`; срок через `fmtDate`. + - Карточка: раздел «Команды хаба» вместо «Команды моста». + - `ProjectGate`: «проект NOPE не найден» и подсказка «выберите проект в списке «Проект» боковой панели (на узком экране — в «Меню»)». +- [ ] **Step 2:** `cd web && npx vitest run src/modules src/projects` — FAIL. +- [ ] **Step 3:** реализация. Горизонтальная прокрутка «Задач» на 1440 px: таблица прокручивается внутри своей панели (`overflow-x: auto` у обёртки таблицы, ширина обёртки не больше контейнера страницы). +- [ ] **Step 4:** `cd web && npm run lint && npm test` — PASS. +- [ ] **Step 5: Commit** — `fix(web-modules): страницы модулей — шапки, «Вики» с корнем, просрочка, прокрутка «Задач»`. + +### Task 8: e2e, документация, спека + +**Files:** +- Modify: `web/e2e/*.spec.ts` (новые адреса, «Обзор», блок состояния, перенаправления), `web/e2e/hub.ts` (фикстура пакетов, если нужна), `ARCHITECTURE.md` (раздел «Веб»: контракт `HubModule` с `status`/`Summary`, трекер в `shell/activity/`, адреса), `CLAUDE.md` (адреса веба, «Обзор»), `README.md` (адреса веба) +- Test: `web/e2e/layout.spec.ts`, `web/e2e/screens.spec.ts`, новый `web/e2e/overview.spec.ts` + +- [ ] **Step 1: Тесты e2e** (тестовый хаб): `overview.spec.ts` — `/app/` → `/app/?project=…`, две карточки, список пакетов; `layout.spec.ts` — на 1440 px у «Задач» `document.documentElement.scrollWidth === window.innerWidth`; на «Вики» `getComputedStyle(h1).fontSize === "20px"`; `/app/board` → `/app/jira/board`; на 390 px — метка проекта в верхней строке, «Меню» модально; `screens.spec.ts` — скриншоты «Обзора» и панели в обеих темах (как прочие экраны). +- [ ] **Step 2:** `cd web && npm run e2e` — новые тесты падают до правок e2e-хелперов, затем PASS. +- [ ] **Step 3:** документация. +- [ ] **Step 4:** `uv run pytest -q`, `cd web && npm run lint && npm test && npm run e2e`, `openspec validate web-modules --strict` — PASS. +- [ ] **Step 5: Commit** — `docs(web-modules): ARCHITECTURE, CLAUDE.md, README, e2e под модули`. -- 2.47.2 From 25973ff97192969d4cfea0878706a0c26cf7ce2b Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 13:06:10 +0400 Subject: [PATCH 03/19] =?UTF-8?q?feat(web-modules):=20API=20=E2=80=94=20?= =?UTF-8?q?=D1=81=D0=B2=D0=B5=D0=B6=D0=B5=D1=81=D1=82=D1=8C=20Jira=20?= =?UTF-8?q?=D0=B2=20=D0=BF=D1=80=D0=BE=D0=B5=D0=BA=D1=82=D0=B5,=20=D0=BC?= =?UTF-8?q?=D0=BE=D0=B4=D1=83=D0=BB=D1=8C=20=D0=BF=D0=B0=D0=BA=D0=B5=D1=82?= =?UTF-8?q?=D0=B0,=20=D0=BF=D0=B0=D0=BA=D0=B5=D1=82=D1=8B=20=D0=BD=D0=BE?= =?UTF-8?q?=D0=B2=D0=BE=D0=B3=D0=BE=20=D0=BF=D1=80=D0=BE=D0=B5=D0=BA=D1=82?= =?UTF-8?q?=D0=B0=20=D0=BF=D0=BE=20=D0=BC=D0=BE=D0=B4=D1=83=D0=BB=D1=8F?= =?UTF-8?q?=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- openspec/changes/web-modules/design.md | 3 ++ src/sdlc_hub/core/api.py | 22 +++++++---- src/sdlc_hub/core/queue.py | 16 ++++++-- src/sdlc_hub/modules/jira/mirror.py | 20 +++++++--- src/sdlc_hub/modules/jira/project_fields.py | 9 ++++- tests/test_db.py | 2 +- tests/test_project_fields.py | 30 ++++++++++++-- tests/test_web_app.py | 4 +- tests/test_wiki_projects.py | 43 ++++++++++++++++++++- 9 files changed, 124 insertions(+), 25 deletions(-) diff --git a/openspec/changes/web-modules/design.md b/openspec/changes/web-modules/design.md index 5a0c5b5..8d23646 100644 --- a/openspec/changes/web-modules/design.md +++ b/openspec/changes/web-modules/design.md @@ -116,3 +116,6 @@ type HubModule = { | 12 | 24.10.2026 | Форматтеры дат в ядре | три формата дат, импорт wiki → jira | — | | 13 | 24.10.2026 | Сводка «Обзора» от модуля | ядро не знает данных модулей | — | | 14 | 24.10.2026 | Архив после sdlc-rename | дельты поверх новых имён | правка дельт при архиве | +| 15 | 24.10.2026 | `last_full_sync(conn, base_jql=None)` в `modules/jira/mirror.py`: базовая JQL необязательна (по умолчанию `projects.base_jql`), `board()` передаёт свою; импорт в `project_fields.view` отложенный | интерфейс плана `(conn)` сохранён, `board()` не меняет сигнатуру; прямой импорт даёт цикл mirror → history → projects → project_fields | вернуть явный параметр | +| 16 | 24.10.2026 | Фильтр `module` в `list_batches` применяется до `limit` перебором пакетов (по типу первой команды), без SQL-колонки | у пакета нет колонки модуля, схему не менять; объём пакетов невелик | добавить колонку `batch.module` | +| 17 | 24.10.2026 | Тесты Task 1 лежат в `test_wiki_projects.py`/`test_project_fields.py` (нужны оба модуля), а не в `test_queue_core.py`/`test_api.py`; правлены старые ожидания в `test_db.py` (`jira_`-поля) | в `test_queue_core` модули подменены заглушкой | перенести тесты | diff --git a/src/sdlc_hub/core/api.py b/src/sdlc_hub/core/api.py index 74226ed..fa8f671 100644 --- a/src/sdlc_hub/core/api.py +++ b/src/sdlc_hub/core/api.py @@ -213,7 +213,7 @@ def h_app(hub, conn, m, q, body): 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"}) + return 302, Raw("text/html; charset=utf-8", b"", {"Location": "/app/", "Cache-Control": "no-cache"}) def h_batches_post(hub, conn, m, q, body): @@ -237,7 +237,11 @@ def h_batches_post(hub, conn, m, q, body): def h_batches_get(hub, conn, m, q, body): - return 200, {"batches": queue.list_batches(conn, int_param(q, "limit", 20), project=q.get("project") or None)} + module = q.get("module") or None + if module is not None and registry.module(module) is None: + raise ApiError(404, "not_found", f"module {module}") + return 200, {"batches": queue.list_batches(conn, int_param(q, "limit", 20), project=q.get("project") or None, + module=module)} def h_batch_get(hub, conn, m, q, body): @@ -420,7 +424,7 @@ def h_projects_post(hub, conn, m, q, body): except projects.ProjectError as e: raise ApiError(422, "bad_project", str(e)) by_module = _module_fields(body, {"label", "name"}, editable_only=False) - batch_ids = [] + batches = [] try: with db.transaction(conn): project = projects.create(conn, label, name) @@ -428,7 +432,7 @@ def h_projects_post(hub, conn, m, q, body): batch = mod.on_project_created(conn, project, by_module.get(mod.name, (mod, {}))[1], author) \ if mod.on_project_created else None if batch is not None: - batch_ids.append(batch["id"]) + batches.append({"module": mod.name, "id": batch["id"]}) row = project_rows(conn, [label])[0] except projects.ProjectExists as e: raise ApiError(409, "project_exists", str(e)) @@ -436,7 +440,8 @@ def h_projects_post(hub, conn, m, q, body): raise ApiError(422, "bad_project", str(e)) except CommandError as e: raise ApiError(422, "bad_batch", str(e)) - return 201, {"project": row, "batch_id": batch_ids[0] if batch_ids else None} + return 201, {"project": row, "batch_id": batches[0]["id"] if batches else None, + "batches": batches} def h_project_patch(hub, conn, m, q, body): @@ -445,16 +450,17 @@ def h_project_patch(hub, conn, m, q, body): project = not_found(projects.get(conn, label), f"project {label}") author = _author(body) by_module = _module_fields(body, {"label", "name", "created_at"}, editable_only=True) - batch_ids = [] + batches = [] try: with db.transaction(conn): for mod, fields in by_module.values(): if mod.on_project_updated: - batch_ids += [b["id"] for b in mod.on_project_updated(conn, project, fields, author)] + batches += [{"module": mod.name, "id": b["id"]} + for b in mod.on_project_updated(conn, project, fields, author)] row = project_rows(conn, [label])[0] except CommandError as e: raise ApiError(422, "bad_batch", str(e)) - return 200, {"project": row, "batch_ids": batch_ids} + return 200, {"project": row, "batch_ids": [b["id"] for b in batches], "batches": batches} CORE_ROUTES = [ diff --git a/src/sdlc_hub/core/queue.py b/src/sdlc_hub/core/queue.py index 2f6f6e8..f3b7f24 100644 --- a/src/sdlc_hub/core/queue.py +++ b/src/sdlc_hub/core/queue.py @@ -154,16 +154,26 @@ def batch_view(conn, batch_id, with_commands=True) -> dict | None: d = dict(b) d["status"] = _status_of(cmds, b["cancelled_at"]) d["counts"] = {s: sum(1 for c in cmds if c["status"] == s) for s in sorted({c["status"] for c in cmds})} + first = registry.module_of(cmds[0]["type"]) if cmds else None + d["module"] = first.name if first else None d["remaining_seconds"] = estimate(conn, [c["type"] for c in cmds if c["status"] in ("queued", "claimed")]) if with_commands: d["commands"] = cmds return d -def list_batches(conn, limit=20, project=None) -> list[dict]: +def list_batches(conn, limit=20, project=None, module=None) -> list[dict]: + """Последние пакеты (новые первыми); module — только пакеты этого модуля (по типу первой команды), фильтр + применяется до limit.""" where, args = ("WHERE project = ?", [project]) if project is not None else ("", []) - ids = [r["id"] for r in conn.execute(f"SELECT id FROM batch {where} ORDER BY id DESC LIMIT ?", (*args, int(limit)))] - return [batch_view(conn, i, with_commands=False) for i in ids] + out = [] + for r in conn.execute(f"SELECT id FROM batch {where} ORDER BY id DESC", args).fetchall(): + if len(out) >= int(limit): + break + view = batch_view(conn, r["id"], with_commands=False) + if module is None or view["module"] == module: + out.append(view) + return out # --- выдача ----------------------------------------------------------------- diff --git a/src/sdlc_hub/modules/jira/mirror.py b/src/sdlc_hub/modules/jira/mirror.py index 0637105..b90912c 100644 --- a/src/sdlc_hub/modules/jira/mirror.py +++ b/src/sdlc_hub/modules/jira/mirror.py @@ -205,6 +205,19 @@ def get_issue(conn, key, files_dir=None) -> dict | None: return d +def last_full_sync(conn, base_jql=None) -> dict | None: + """Последний успешный полный прогон (refresh по базовой JQL, команда done): started_at, finished_at, issues_count. + base_jql не задана — берётся текущая базовая JQL проектов; без проектов — None.""" + if base_jql is None: + from . import projects + base_jql = projects.base_jql(conn) + run = conn.execute( + "SELECT s.started_at, s.finished_at, s.issues_count FROM sync_run s JOIN command c ON c.id = s.command_id " + "WHERE s.kind = 'jql' AND s.query = ? AND s.finished_at IS NOT NULL AND c.status = 'done' " + "ORDER BY s.started_at DESC, s.id DESC LIMIT 1", (base_jql,)).fetchone() + return dict(run) if run else None + + BOARD_FIELDS = ["key", "summary", "issue_type", "status", "priority", "assignee", "sprint", "duedate", "planned_start", "planned_end", "epic_key", "labels", "neighbor"] @@ -234,14 +247,11 @@ def board(conn, base_jql, project: str | None = None) -> dict: links = [dict(r) for r in conn.execute( "SELECT source_key AS source, target_key AS target, link_type FROM issue_link " "ORDER BY source_key, target_key, link_type")] - run = conn.execute( - "SELECT s.started_at, s.finished_at, s.issues_count FROM sync_run s JOIN command c ON c.id = s.command_id " - "WHERE s.kind = 'jql' AND s.query = ? AND s.finished_at IS NOT NULL AND c.status = 'done' " - "ORDER BY s.started_at DESC, s.id DESC LIMIT 1", (base_jql,)).fetchone() + run = last_full_sync(conn, base_jql) finally: if own: conn.execute("COMMIT") - return {"generated_at": db.now(), "last_sync": dict(run) if run else None, "issues": issues, "links": links} + return {"generated_at": db.now(), "last_sync": run, "issues": issues, "links": links} def get_note(conn, key) -> dict | None: diff --git a/src/sdlc_hub/modules/jira/project_fields.py b/src/sdlc_hub/modules/jira/project_fields.py index 2a6e426..579680e 100644 --- a/src/sdlc_hub/modules/jira/project_fields.py +++ b/src/sdlc_hub/modules/jira/project_fields.py @@ -13,7 +13,8 @@ FIELDS = (ProjectField("board_id", validate_board, editable=False),) def view(conn, labels: list[str]) -> dict[str, dict]: - """Значения полей для списка проектов: метка → board_id, board_type, issues_count.""" + """Значения полей для списка проектов: метка → board_id, board_type, issues_count, jira_synced_at (finished_at + последнего полного обновления — общее для всех проектов; null до первого).""" if not labels: return {} marks = ", ".join("?" * len(labels)) @@ -21,5 +22,9 @@ def view(conn, labels: list[str]) -> dict[str, dict]: "SELECT p.label, jp.board_id, jp.board_type, (SELECT COUNT(*) FROM issue i WHERE EXISTS " "(SELECT 1 FROM json_each(i.labels) WHERE value = p.label)) AS issues_count " f"FROM project p LEFT JOIN jira_project jp ON jp.label = p.label WHERE p.label IN ({marks})", labels) - return {r["label"]: {"board_id": r["board_id"], "board_type": r["board_type"], "issues_count": r["issues_count"]} + from .mirror import last_full_sync # отложенный импорт: mirror → history → projects → project_fields + run = last_full_sync(conn) + synced = run["finished_at"] if run else None + return {r["label"]: {"board_id": r["board_id"], "board_type": r["board_type"], "issues_count": r["issues_count"], + "jira_synced_at": synced} for r in rows} diff --git a/tests/test_db.py b/tests/test_db.py index 92ecfe9..f1076f7 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -280,7 +280,7 @@ def test_migrates_v3_creates_awpca(tmp_path): _v3_database(path) conn = db.connect(path) new = 'project = AURORA AND labels in ("AWPCA")' - assert {k: v for k, v in api.project_rows(conn)[0].items() if not k.startswith("wiki_")} | { + assert {k: v for k, v in api.project_rows(conn)[0].items() if not k.startswith(("wiki_", "jira_"))} | { "created_at": None, "issues_count": None} == { "label": "AWPCA", "name": "АРМ КА", "board_id": 4216, "board_type": None, "created_at": None, "issues_count": None} assert settings.get(conn, "base_jql") == "project = AURORA AND labels = AWPCA" # ключи на месте diff --git a/tests/test_project_fields.py b/tests/test_project_fields.py index b24a563..a2ed962 100644 --- a/tests/test_project_fields.py +++ b/tests/test_project_fields.py @@ -34,7 +34,8 @@ def test_projects_get_shape(hub_server, add_project): got = hub_server["client"].get("/projects")["projects"] assert got == [{"label": "AWPCA", "name": "АРМ КА", "created_at": got[0]["created_at"], "board_id": 4216, "board_type": "scrum", "issues_count": 1, "wiki_root": None, - "wiki_root_title": None, "wiki_pages": None, "wiki_synced_at": None, "wiki_sync_error": None}] + "wiki_root_title": None, "wiki_pages": None, "wiki_synced_at": None, "wiki_sync_error": None, + "jira_synced_at": None}] def test_post_unknown_field(hub_server): @@ -47,7 +48,7 @@ def test_post_unknown_field(hub_server): def test_post_response_shape(hub_server): out = hub_server["client"].post("/projects", {"label": "X", "name": "X", "board_id": 7}) - assert set(out) == {"project", "batch_id"} and out["project"]["board_id"] == 7 + assert set(out) == {"project", "batch_id", "batches"} and out["project"]["board_id"] == 7 assert hub_server["client"].get(f"/batches/{out['batch_id']}")["title"] == "Новый проект X" @@ -205,7 +206,8 @@ def test_module_fields_post_and_patch(two_modules): api, ApiError, conn, re = two_modules code, out = api.h_projects_post(None, conn, None, {}, {"label": "P", "name": "p", "board_id": 3, "root": "doc-1"}) assert code == 201 and out["project"]["root"] == "DOC-1" and out["project"]["board_id"] == 3 - assert set(out) == {"project", "batch_id"} and out["batch_id"] is not None # пакет только у jira + assert set(out) == {"project", "batch_id", "batches"} and out["batch_id"] is not None # пакет только у jira + assert [b["module"] for b in out["batches"]] == ["jira"] code, out = api.h_project_patch(None, conn, re.match(r"(.*)", "P"), {}, {"root": "doc-2"}) assert code == 200 and out["project"]["root"] == "DOC-2" and len(out["batch_ids"]) == 1 with pytest.raises(ApiError) as e: @@ -220,3 +222,25 @@ def test_module_field_error_writes_nothing(two_modules): api.h_projects_post(None, conn, None, {}, {"label": "P", "name": "p", "root": 5}) assert e.value.status == 422 and e.value.detail.startswith("root") assert api.project_rows(conn) == [] + + +def test_projects_jira_synced_at(hub_server, add_project): + """jira_synced_at — finished_at последнего полного обновления; до него — null у каждого проекта.""" + h, c = hub_server["hub"], hub_server["client"] + conn = h.conn() + try: + add_project(conn, "AWPCA") + add_project(conn, "OTHER", board_id=None) + finally: + conn.close() + assert [p["jira_synced_at"] for p in c.get("/projects")["projects"]] == [None, None] + c.post("/refresh", {"full": True}) + cmd = hub_server["agent"].post("/agent/claim", {})["command"] + hub_server["agent"].post("/agent/state", {"command_id": cmd["id"], "issues": [], "links": [], "neighbor_keys": []}) + hub_server["agent"].post(f"/agent/commands/{cmd['id']}/result", {"status": "done"}) + conn = h.conn() + try: + conn.execute("UPDATE sync_run SET finished_at = '2026-10-24T11:07:00' WHERE command_id = ?", (cmd["id"],)) + finally: + conn.close() + assert [p["jira_synced_at"] for p in c.get("/projects")["projects"]] == ["2026-10-24T11:07:00"] * 2 diff --git a/tests/test_web_app.py b/tests/test_web_app.py index 7f55cb5..a74ec7d 100644 --- a/tests/test_web_app.py +++ b/tests/test_web_app.py @@ -104,10 +104,10 @@ def test_default_web_dir_is_package_build(tmp_path): @pytest.mark.parametrize("path", ["/", "/links"]) -def test_old_board_addresses_redirect_to_app(app_hub, path): +def test_root_redirect(app_hub, path): status, r, body = _get(app_hub, path) assert status == 302 - assert r.getheader("Location") == "/app/board" + assert r.getheader("Location") == "/app/" assert body == b"" diff --git a/tests/test_wiki_projects.py b/tests/test_wiki_projects.py index d8a053b..fe51f38 100644 --- a/tests/test_wiki_projects.py +++ b/tests/test_wiki_projects.py @@ -28,7 +28,7 @@ def _syncs(hub, label=None): def test_post_project_with_root(hub_server): out = hub_server["client"].post("/projects", {"label": "S", "name": "S", "wiki_root": "DOC-001234"}) - assert set(out) == {"project", "batch_id"} and out["project"]["wiki_root"] == "DOC-001234" + assert set(out) == {"project", "batch_id", "batches"} and out["project"]["wiki_root"] == "DOC-001234" hub = hub_server["hub"] conn = hub.conn() try: @@ -149,3 +149,44 @@ def test_wiki_mcp_imports_no_db_or_queue(): mods += [a.name for a in n.names] bad = [m for m in mods if m.endswith((".db", ".queue", ".projects")) or ".core.db" in m or ".core.queue" in m] assert not bad, (name, bad) + + +def test_batch_module(hub_server): + """Модуль пакета — по типу первой команды; пакет без команд — None (list_batches и batch_view).""" + c, hub = hub_server["client"], hub_server["hub"] + out = c.post("/projects", {"label": "S", "name": "S", "wiki_root": "DOC-001234"}) + wiki_id = out["batches"][1]["id"] + conn = hub.conn() + try: + cur = conn.execute("INSERT INTO batch(title, author, created_at) VALUES ('пустой', 'api', '2026-10-24T10:00:00')") + empty_id = cur.lastrowid + finally: + conn.close() + assert c.get(f"/batches/{out['batch_id']}")["module"] == "jira" + assert c.get(f"/batches/{wiki_id}")["module"] == "wiki" + assert c.get(f"/batches/{empty_id}")["module"] is None + assert {b["id"]: b["module"] for b in c.get("/batches")["batches"]} == { + out["batch_id"]: "jira", wiki_id: "wiki", empty_id: None} + + +def test_batches_module_filter(hub_server): + c = hub_server["client"] + c.post("/projects", {"label": "AWPCA", "name": "A"}) + out = c.patch("/projects/AWPCA", {"wiki_root": "DOC-000777"}) + got = c.get("/batches", module="wiki", project="AWPCA")["batches"] + assert [b["id"] for b in got] == out["batch_ids"] and {b["module"] for b in got} == {"wiki"} + assert [b["module"] for b in c.get("/batches", module="jira")["batches"]] == ["jira"] + with pytest.raises(HubError) as e: + c.get("/batches", module="nope") + assert e.value.status == 404 and e.value.error == "not_found" + + +def test_create_project_batches(hub_server): + c = hub_server["client"] + out = c.post("/projects", {"label": "S", "name": "S", "wiki_root": "DOC-001234"}) + ids = [b["id"] for b in out["batches"]] + assert out["batches"] == [{"module": "jira", "id": ids[0]}, {"module": "wiki", "id": ids[1]}] + assert out["batch_id"] == ids[0] + c.post("/projects", {"label": "AWPCA", "name": "A"}) + patched = c.patch("/projects/AWPCA", {"wiki_root": "DOC-000777"}) + assert patched["batches"] == [{"module": "wiki", "id": patched["batch_ids"][0]}] -- 2.47.2 From 34614cb6224d498295435cab33db5fc4bcda5203 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 13:06:48 +0400 Subject: [PATCH 04/19] =?UTF-8?q?spec(web-modules):=20R-1..R-3=20=D0=B2=20?= =?UTF-8?q?design.md=20(=D1=80=D0=B5=D1=88=D0=B5=D0=BD=D0=B8=D1=8F=2018?= =?UTF-8?q?=E2=80=9320)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- openspec/changes/web-modules/design.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/openspec/changes/web-modules/design.md b/openspec/changes/web-modules/design.md index 8d23646..3d5461b 100644 --- a/openspec/changes/web-modules/design.md +++ b/openspec/changes/web-modules/design.md @@ -119,3 +119,6 @@ type HubModule = { | 15 | 24.10.2026 | `last_full_sync(conn, base_jql=None)` в `modules/jira/mirror.py`: базовая JQL необязательна (по умолчанию `projects.base_jql`), `board()` передаёт свою; импорт в `project_fields.view` отложенный | интерфейс плана `(conn)` сохранён, `board()` не меняет сигнатуру; прямой импорт даёт цикл mirror → history → projects → project_fields | вернуть явный параметр | | 16 | 24.10.2026 | Фильтр `module` в `list_batches` применяется до `limit` перебором пакетов (по типу первой команды), без SQL-колонки | у пакета нет колонки модуля, схему не менять; объём пакетов невелик | добавить колонку `batch.module` | | 17 | 24.10.2026 | Тесты Task 1 лежат в `test_wiki_projects.py`/`test_project_fields.py` (нужны оба модуля), а не в `test_queue_core.py`/`test_api.py`; правлены старые ожидания в `test_db.py` (`jira_`-поля) | в `test_queue_core` модули подменены заглушкой | перенести тесты | +| 18 | 24.10.2026 | R-1: трейлер коммитов ветки — `Co-Authored-By: Claude Sonnet 5.5`, а не `Opus 5.5` из плана | системная подсказка сессии об атрибуции приоритетнее текста плана | переписать трейлеры | +| 19 | 24.10.2026 | R-2: предусловие плана («после слияния #97 в main») считается выполненным базированием на `feat/97-sdlc-rename` | указание владельца; PR #99 ещё не слит | ребейз на main после слияния #99 | +| 20 | 24.10.2026 | R-3: параметр `q` страницы «Вики» реализует Task 5, Task 7 только добавляет тест | в плане требование задвоено между Task 5 и Task 7 | мелкая правка | -- 2.47.2 From 5061d9a3f0b6961a0df248bb8fbe855e591e566f Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 13:13:23 +0400 Subject: [PATCH 05/19] =?UTF-8?q?refactor(web-modules):=20=D0=B3=D1=80?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D1=86=D0=B0=20=D1=8F=D0=B4=D1=80=D0=B0=20?= =?UTF-8?q?=D0=B8=20=D0=BC=D0=BE=D0=B4=D1=83=D0=BB=D0=B5=D0=B9=20=D0=B2=20?= =?UTF-8?q?=D0=B2=D0=B5=D0=B1=D0=B5,=20=D1=84=D0=BE=D1=80=D0=BC=D0=B0?= =?UTF-8?q?=D1=82=D1=82=D0=B5=D1=80=D1=8B=20=D0=B4=D0=B0=D1=82=20=D0=B2=20?= =?UTF-8?q?=D1=8F=D0=B4=D1=80=D0=B5,=20=D0=B1=D0=B5=D0=B7=20fact=5Fstatus?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- openspec/changes/web-modules/design.md | 4 + web/src/App.tsx | 2 +- web/src/api/types.ts | 88 ++++--------------- web/src/boundary.test.ts | 85 ++++++++++++++++++ web/src/{ => modules/jira}/api/closed.test.ts | 0 web/src/{ => modules/jira}/api/closed.ts | 6 +- web/src/modules/jira/api/queries.test.ts | 2 +- web/src/modules/jira/api/queries.ts | 2 +- web/src/modules/jira/api/types.ts | 73 +++++++++++++++ web/src/modules/jira/board/BoardPage.tsx | 2 +- .../jira/board/analysis/analysis.test.ts | 2 +- .../board/components/BoardCanvas.test.tsx | 2 +- .../jira/board/components/BoardCanvas.tsx | 2 +- web/src/modules/jira/board/dicts.ts | 4 +- .../modules/jira/board/layout/layout.test.ts | 2 +- web/src/modules/jira/board/model/fromHub.ts | 2 +- .../modules/jira/board/model/model.test.ts | 2 +- web/src/modules/jira/board/model/util.test.ts | 6 +- web/src/modules/jira/board/model/util.ts | 4 - web/src/modules/jira/board/storage.test.ts | 2 +- web/src/modules/jira/board/useBoardSource.ts | 3 +- web/src/modules/jira/feed/EventList.tsx | 2 +- web/src/modules/jira/feed/FeedPage.test.tsx | 2 +- web/src/modules/jira/feed/FeedPage.tsx | 2 +- web/src/modules/jira/feed/events.test.ts | 2 +- web/src/modules/jira/feed/events.ts | 2 +- .../modules/jira/issues/IssueCard.test.tsx | 6 +- web/src/modules/jira/issues/IssueCard.tsx | 6 +- web/src/modules/jira/issues/IssueTable.tsx | 5 +- .../modules/jira/issues/IssuesPage.test.tsx | 2 +- web/src/modules/jira/issues/IssuesPage.tsx | 2 +- web/src/modules/jira/issues/NoteForm.tsx | 2 +- web/src/modules/jira/issues/Notes.tsx | 4 +- .../issues/attachments/AttachmentChips.tsx | 2 +- .../issues/attachments/AttachmentList.tsx | 4 +- .../useAttachmentDownload.test.tsx | 2 +- .../attachments/useAttachmentDownload.ts | 2 +- web/src/modules/jira/issues/card.ts | 4 +- web/src/modules/jira/issues/options.ts | 5 +- .../issues/wiki/WikiText.fallback.test.tsx | 2 +- .../jira/issues/wiki/WikiText.test.tsx | 2 +- web/src/modules/jira/issues/wiki/WikiText.tsx | 4 +- web/src/modules/jira/issues/wiki/wiki.css | 42 ++++----- web/src/modules/jira/refresh/DataStatus.tsx | 2 +- web/src/modules/modules.test.tsx | 32 ++++++- web/src/modules/wiki/PageView.tsx | 2 +- web/src/modules/wiki/SyncStatus.tsx | 2 +- .../{modules => shell}/ModuleProviders.tsx | 2 +- web/src/shell/NotFound.tsx | 2 +- web/src/test/issueFixtures.ts | 4 +- web/src/ui/format.test.ts | 16 ++++ web/src/ui/format.ts | 18 ++++ 52 files changed, 324 insertions(+), 157 deletions(-) create mode 100644 web/src/boundary.test.ts rename web/src/{ => modules/jira}/api/closed.test.ts (100%) rename web/src/{ => modules/jira}/api/closed.ts (77%) create mode 100644 web/src/modules/jira/api/types.ts rename web/src/{modules => shell}/ModuleProviders.tsx (88%) create mode 100644 web/src/ui/format.test.ts create mode 100644 web/src/ui/format.ts diff --git a/openspec/changes/web-modules/design.md b/openspec/changes/web-modules/design.md index 3d5461b..88bc8c5 100644 --- a/openspec/changes/web-modules/design.md +++ b/openspec/changes/web-modules/design.md @@ -122,3 +122,7 @@ type HubModule = { | 18 | 24.10.2026 | R-1: трейлер коммитов ветки — `Co-Authored-By: Claude Sonnet 5.5`, а не `Opus 5.5` из плана | системная подсказка сессии об атрибуции приоритетнее текста плана | переписать трейлеры | | 19 | 24.10.2026 | R-2: предусловие плана («после слияния #97 в main») считается выполненным базированием на `feat/97-sdlc-rename` | указание владельца; PR #99 ещё не слит | ребейз на main после слияния #99 | | 20 | 24.10.2026 | R-3: параметр `q` страницы «Вики» реализует Task 5, Task 7 только добавляет тест | в плане требование задвоено между Task 5 и Task 7 | мелкая правка | +| 21 | 24.10.2026 | Task 2: `ModuleProviders.tsx` перенесён из `modules/` в `shell/` (импортирует только `modules/index.ts`) | тест-страж границы допускает из ядра лишь `modules/index.ts` и `modules/types.ts`; `App.tsx` импортировал `modules/ModuleProviders.tsx` | вернуть файл и ослабить страж | +| 22 | 24.10.2026 | Task 2: `fmtDate` доски (`board/model/util.ts`, принимает не-ISO даты Jira, год не показывает) остаётся в модуле; в ядре `ui/format.ts` — свой `fmtDate`, таблица и карточка задач берут его (даты другого года — `ДД.ММ.ГГГГ`); `fmtStamp(null)` → «—» | задача требует форматтеры в ядре; доска сравнивает строки дат Jira в разных локалях | свести две функции в одну | +| 23 | 24.10.2026 | Task 2: словари jira-настроек — тип `JiraSettings extends HubSettings` в `modules/jira/api/types.ts`; `HubSettings` ядра — только `project_key` и индексная подпись | страж запрещает термины jira в ядре | вернуть словари в `HubSettings` | + diff --git a/web/src/App.tsx b/web/src/App.tsx index bd82f5b..5a093bb 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -3,7 +3,7 @@ import { useState } from "react"; import { createBrowserRouter, RouterProvider } from "react-router"; import { makeQueryClient } from "./api/queryClient.ts"; import { AuthGate } from "./auth/AuthGate.tsx"; -import { ModuleProviders } from "./modules/ModuleProviders.tsx"; +import { ModuleProviders } from "./shell/ModuleProviders.tsx"; import { routes } from "./routes.tsx"; import "./styles/tokens.css"; import "./styles/base.css"; diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 20d79bb..295149a 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -1,77 +1,12 @@ -// ответы HTTP API хаба (openspec/specs/hub-api); поля — как отдаёт хаб, без переименований +// ответы HTTP API хаба, общие для ядра (openspec/specs/hub-api); типы модулей — в modules/<модуль>/api/types.ts -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, -// closed_statuses — статусы Jira закрытых задач +// настройки хаба: ядро знает только метку проекта; словари модулей (jira) +// типизирует сам модуль export interface HubSettings { - kind_labels?: Record; - link_types?: Record; - closed_statuses?: unknown; project_key?: 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; -} - -// поля заметки, которые приложение показывает и правит; fact_status хаб ещё отдаёт, приложение его не читает (hide-closed) -export const NOTE_FIELDS = ["work_kind", "done_text", "rest_text", "owner_note"] as const; -export type NoteField = (typeof NOTE_FIELDS)[number]; -export type NoteFields = Partial>; -export type Note = Record & { key: string; fact_status?: string | null; 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 IssueLinkOut { target_key: string; link_type: string; summary: string | null; status: string | null } -export interface IssueLinkIn { source_key: string; link_type: string; summary: string | null; status: string | null } -// комментарий задачи из Jira (зеркало хаба, агент v10+): текст и время — как в Jira -export interface IssueComment { - comment_id: string; author: string | null; created: string | null; updated: string | null; body: string; - attachment_ids?: string[]; // id вложений, на которые ссылается текст (хаб с вложениями, схема v6) -} -// вложение задачи (хаб со схемой v6): state — ready (файл у хаба) / fetching / failed / none; error — причина при failed -export type AttachmentStateName = "ready" | "fetching" | "failed" | "none"; -export interface IssueAttachment { - attachment_id: string; filename: string; size: number | null; mime_type: string | null; author: string | null; - created: string | null; state: AttachmentStateName; error: string | null; -} -// ответ GET /attachments/{id} и POST /attachments/{id}/fetch -export interface AttachmentState extends IssueAttachment { key: string; command_id: number | null; batch_id: number | null } -// событие журнала изменений (GET /changes, GET /issues/{key}.changes; в истории задачи нет key/summary/status) -export type ChangeKind = "status" | "description" | "dates" | "comment" | "other"; -export interface ChangeEvent { - at: string | null; key?: string; summary?: string | null; status?: string | null; kind: ChangeKind; field: string; - author: string | null; from: string | null; to: string | null; from_value: string | null; to_value: string | null; -} -export interface ChangesResponse { since: string; until: string; truncated: boolean; changes: ChangeEvent[] } - -export interface IssueDetail extends IssueRow { - epic_summary: string | null; - links_out: IssueLinkOut[]; - links_in: IssueLinkIn[]; - note: Note | null; - commands: IssueCommand[]; - comments: IssueComment[]; - attachments?: IssueAttachment[]; // хаб до вложений поля не шлёт - description_attachment_ids?: string[]; - changes?: ChangeEvent[]; // хаб до журнала изменений (#70) поля не шлёт -} - 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; @@ -79,12 +14,21 @@ export interface BatchCommand { export interface Batch { id: number; title: string; author: string; created_at: string; cancelled_at: string | null; status: BatchStatus; counts: Record; remaining_seconds: number; commands?: BatchCommand[]; project?: string | null; + module: string | null; // модуль пакета (по типу первой команды); null — не определён +} +// ответ GET /agents (по записи на модуль) +export interface AgentView { + module: string; online: boolean; last_seen_at: string | null; agent_version: number | null; + warning: string | null; update_hint: string | null; current_command_id: number | null; + release: { version: number } | null; } -export interface AgentView { online: boolean; last_seen_at: string | null; warning: string | null } export interface CreatedBatch { id: number; estimate_seconds: number; count: number } -// проект-метка (GET/POST /projects): ядро знает метку, название и дату; поля модулей (jira: board_id, board_type, -// issues_count) типизирует сам модуль — см. modules/jira/projectFields.ts -export type Project = { label: string; name: string; created_at: string } & Record; +// проект-метка (GET/POST /projects): ядро знает метку, название, дату, время обновления из Jira и последние пакеты; +// остальные поля модулей (jira: board_id, board_type, issues_count) типизирует сам модуль — modules/jira/projectFields.ts +export type Project = { + label: string; name: string; created_at: string; + jira_synced_at: string | null; batches?: Batch[]; +} & Record; export interface CreatedProject { project: Project; batch_id: number } export interface UpdatedProject { project: Project; batch_ids: number[] } diff --git a/web/src/boundary.test.ts b/web/src/boundary.test.ts new file mode 100644 index 0000000..d9a153d --- /dev/null +++ b/web/src/boundary.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "vitest"; + +// граница ядра и модулей (design web-modules 10–12): правила проверяются по исходникам +const raw = (g: Record): Record => + Object.fromEntries(Object.entries(g).map(([k, v]) => [k.replace(/^\.\//, "src/"), v as string])); + +const SRC = raw(import.meta.glob("./**/*.{ts,tsx}", { query: "?raw", import: "default", eager: true })); +const CSS = raw(import.meta.glob("./**/*.css", { query: "?raw", import: "default", eager: true })); + +const CORE = /^src\/(shell|ui|api|projects|auth|styles)\//; +const isTest = (f: string) => /\.test\.tsx?$/.test(f) || f.includes("/test/"); +const coreFiles = Object.keys(SRC).filter((f) => CORE.test(f) && !isTest(f)); +const imports = (text: string): string[] => + [...text.matchAll(/(?:from|import)\s*\(?\s*["']([^"']+)["']/g)].map((m) => m[1]); +const moduleOf = (f: string): string | null => /^src\/modules\/([^/]+)\//.exec(f)?.[1] ?? null; +// куда ведёт импорт относительно файла: нормализованный путь от src/ +const MODULE_DIRS = new Set(Object.keys(SRC).map(moduleOf).filter((m): m is string => !!m)); +function resolve(from: string, spec: string): string { + const parts = from.split("/").slice(0, -1); + for (const p of spec.split("/")) { + if (p === "..") parts.pop(); else if (p !== ".") parts.push(p); + } + return parts.join("/"); +} + +describe("граница ядра и модулей", () => { + it("core does not import modules", () => { + const bad: string[] = []; + for (const f of coreFiles) { + for (const spec of imports(SRC[f])) { + if (!spec.startsWith(".")) continue; + const target = resolve(f, spec); + if (target.startsWith("src/modules/") && !/^src\/modules\/(index|types)\.ts$/.test(target)) bad.push(`${f} -> ${spec}`); + } + } + expect(bad).toEqual([]); + }); + + it("module does not import other module", () => { + const bad: string[] = []; + for (const f of Object.keys(SRC)) { + const own = moduleOf(f); + if (!own || isTest(f)) continue; + for (const spec of imports(SRC[f])) { + if (!spec.startsWith(".")) continue; + const other = moduleOf(resolve(f, spec) + "/"); + if (!other || !MODULE_DIRS.has(other)) continue; + if (other !== own) bad.push(`${f} -> ${spec}`); + } + } + expect(bad).toEqual([]); + }); + + it("css classes do not collide", () => { + const classesOf = (css: string): Set => { + const out = new Set(); + const noComments = css.replace(/\/\*[\s\S]*?\*\//g, ""); + // только селекторы: текст перед «{»; значения свойств (0.5, 1.5em) не трогаем + for (const m of noComments.matchAll(/([^{}]+)\{/g)) { + if (/^\s*@(?!container|media|supports)/.test(m[1])) continue; + const sel = m[1].replace(/@(container|media|supports)[^{]*$/, "").replace(/\[[^\]]*\]/g, "").replace(/\([^)]*\)/g, ""); + for (const c of sel.matchAll(/\.([a-zA-Z_][\w-]*)/g)) out.add(c[1]); + } + return out; + }; + const owner = (f: string): string | null => moduleOf(f) ?? (CORE.test(f) ? "core" : null); + const seen = new Map>(); // класс -> владелец -> файл + for (const f of Object.keys(CSS)) { + const o = owner(f); + if (!o) continue; + for (const c of classesOf(CSS[f])) { + if (!seen.has(c)) seen.set(c, new Map()); + if (!seen.get(c)!.has(o)) seen.get(c)!.set(o, f); + } + } + const clashes = [...seen].filter(([, m]) => m.size > 1).map(([c, m]) => `.${c}: ${[...m.values()].join(", ")}`); + expect(clashes).toEqual([]); + }); + + it("core has no module terms", () => { + const TERMS = /\b(fact_status|closed_statuses|kind_labels|link_types|BoardIssue|IssueRow)\b/; + const bad = coreFiles.filter((f) => TERMS.test(SRC[f])).map((f) => `${f}: ${SRC[f].match(TERMS)![1]}`); + expect(bad).toEqual([]); + }); +}); diff --git a/web/src/api/closed.test.ts b/web/src/modules/jira/api/closed.test.ts similarity index 100% rename from web/src/api/closed.test.ts rename to web/src/modules/jira/api/closed.test.ts diff --git a/web/src/api/closed.ts b/web/src/modules/jira/api/closed.ts similarity index 77% rename from web/src/api/closed.ts rename to web/src/modules/jira/api/closed.ts index 3a8cde1..5e4c10b 100644 --- a/web/src/api/closed.ts +++ b/web/src/modules/jira/api/closed.ts @@ -1,9 +1,9 @@ import { useMemo } from "react"; -import { useSettings } from "./queries.ts"; -import type { HubSettings } from "./types.ts"; +import { useSettings } from "../../../api/queries.ts"; +import type { JiraSettings } from "./types.ts"; // закрытые задачи (hide-closed): статус Jira из настройки closed_statuses; нет настройки или она не список — закрытых нет -export const closedStatuses = (s: HubSettings | undefined): Set => +export const closedStatuses = (s: JiraSettings | undefined): Set => new Set(Array.isArray(s?.closed_statuses) ? s.closed_statuses.filter((x): x is string => typeof x === "string") : []); export const isClosed = (status: string | null | undefined, closed: Set): boolean => !!status && closed.has(status); diff --git a/web/src/modules/jira/api/queries.test.ts b/web/src/modules/jira/api/queries.test.ts index 148cb98..fe7aa3b 100644 --- a/web/src/modules/jira/api/queries.test.ts +++ b/web/src/modules/jira/api/queries.test.ts @@ -5,7 +5,7 @@ import { beforeEach, describe, expect, it } from "vitest"; import { setToken } from "../../../auth/token.ts"; import { json, mockFetch } from "../../../test/fetchMock.ts"; import { qk, useBoard, useIssues, useSaveNote } from "./queries.ts"; -import type { IssueDetail } from "../../../api/types.ts"; +import type { IssueDetail } from "./types.ts"; const wrap = (client: QueryClient) => ({ children }: { children: ReactNode }) => createElement(QueryClientProvider, { client }, children); diff --git a/web/src/modules/jira/api/queries.ts b/web/src/modules/jira/api/queries.ts index 9c20183..053eecd 100644 --- a/web/src/modules/jira/api/queries.ts +++ b/web/src/modules/jira/api/queries.ts @@ -1,7 +1,7 @@ import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { api } from "../../../api/client.ts"; import { qk as coreQk } from "../../../api/queries.ts"; -import type { BoardSnapshot, ChangesResponse, IssueDetail, IssueRow, Note, NoteFields } from "../../../api/types.ts"; +import type { BoardSnapshot, ChangesResponse, IssueDetail, IssueRow, Note, NoteFields } from "./types.ts"; // фильтры списка в адресе; hide_closed в адрес не пишется — его выводит список из флажка «скрыть закрытые» (closed=1 — снят) export const ISSUE_FILTER_NAMES = ["sprint", "assignee", "work_kind", "status", "label", "epic", "neighbor", "q"] as const; diff --git a/web/src/modules/jira/api/types.ts b/web/src/modules/jira/api/types.ts new file mode 100644 index 0000000..e611aa3 --- /dev/null +++ b/web/src/modules/jira/api/types.ts @@ -0,0 +1,73 @@ +// ответы HTTP API модуля jira (openspec/specs/hub-api); поля — как отдаёт хаб, без переименований +import type { HubSettings } from "../../../api/types.ts"; + +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; + 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[] } + +// словари настроек jira: kind_labels — вид работы → метка Jira, link_types — логический тип → имя типа связи Jira, +// closed_statuses — статусы Jira закрытых задач +export interface JiraSettings extends HubSettings { + kind_labels?: Record; + link_types?: Record; + closed_statuses?: 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; work_kind?: string | null; +} + +// поля заметки, которые приложение показывает и правит +export const NOTE_FIELDS = ["work_kind", "done_text", "rest_text", "owner_note"] as const; +export type NoteField = (typeof NOTE_FIELDS)[number]; +export type NoteFields = Partial>; +export type Note = Record & { 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 IssueLinkOut { target_key: string; link_type: string; summary: string | null; status: string | null } +export interface IssueLinkIn { source_key: string; link_type: string; summary: string | null; status: string | null } +// комментарий задачи из Jira (зеркало хаба, агент v10+): текст и время — как в Jira +export interface IssueComment { + comment_id: string; author: string | null; created: string | null; updated: string | null; body: string; + attachment_ids?: string[]; // id вложений, на которые ссылается текст (хаб с вложениями, схема v6) +} +// вложение задачи (хаб со схемой v6): state — ready (файл у хаба) / fetching / failed / none; error — причина при failed +export type AttachmentStateName = "ready" | "fetching" | "failed" | "none"; +export interface IssueAttachment { + attachment_id: string; filename: string; size: number | null; mime_type: string | null; author: string | null; + created: string | null; state: AttachmentStateName; error: string | null; +} +// ответ GET /attachments/{id} и POST /attachments/{id}/fetch +export interface AttachmentState extends IssueAttachment { key: string; command_id: number | null; batch_id: number | null } +// событие журнала изменений (GET /changes, GET /issues/{key}.changes; в истории задачи нет key/summary/status) +export type ChangeKind = "status" | "description" | "dates" | "comment" | "other"; +export interface ChangeEvent { + at: string | null; key?: string; summary?: string | null; status?: string | null; kind: ChangeKind; field: string; + author: string | null; from: string | null; to: string | null; from_value: string | null; to_value: string | null; +} +export interface ChangesResponse { since: string; until: string; truncated: boolean; changes: ChangeEvent[] } + +export interface IssueDetail extends IssueRow { + epic_summary: string | null; + links_out: IssueLinkOut[]; + links_in: IssueLinkIn[]; + note: Note | null; + commands: IssueCommand[]; + comments: IssueComment[]; + attachments?: IssueAttachment[]; // хаб до вложений поля не шлёт + description_attachment_ids?: string[]; + changes?: ChangeEvent[]; // хаб до журнала изменений (#70) поля не шлёт +} + diff --git a/web/src/modules/jira/board/BoardPage.tsx b/web/src/modules/jira/board/BoardPage.tsx index 3bbf874..803e000 100644 --- a/web/src/modules/jira/board/BoardPage.tsx +++ b/web/src/modules/jira/board/BoardPage.tsx @@ -1,5 +1,5 @@ import { useCallback, useMemo, useRef, useState } from "react"; -import { useClosedStatuses } from "../../../api/closed.ts"; +import { useClosedStatuses } from "../api/closed.ts"; import { useSettings } from "../../../api/queries.ts"; import { computeVisible, emptyReason } from "./analysis/visible.ts"; import { BoardCanvas } from "./components/BoardCanvas.tsx"; diff --git a/web/src/modules/jira/board/analysis/analysis.test.ts b/web/src/modules/jira/board/analysis/analysis.test.ts index f6c8693..431eb80 100644 --- a/web/src/modules/jira/board/analysis/analysis.test.ts +++ b/web/src/modules/jira/board/analysis/analysis.test.ts @@ -1,7 +1,7 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import type { BoardSnapshot } from "../../../../api/types.ts"; +import type { BoardSnapshot } from "../../api/types.ts"; import { canon } from "../../../../test/canon.ts"; import { dictsFromSettings } from "../dicts.ts"; import settings from "../__fixtures__/settings.json"; diff --git a/web/src/modules/jira/board/components/BoardCanvas.test.tsx b/web/src/modules/jira/board/components/BoardCanvas.test.tsx index fe18821..8caab97 100644 --- a/web/src/modules/jira/board/components/BoardCanvas.test.tsx +++ b/web/src/modules/jira/board/components/BoardCanvas.test.tsx @@ -3,7 +3,7 @@ import { fireEvent, screen, waitFor, within } from "@testing-library/react"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; -import type { BoardSnapshot } from "../../../../api/types.ts"; +import type { BoardSnapshot } from "../../api/types.ts"; import { renderWithProviders } from "../../../../test/render.tsx"; import { computeVisible } from "../analysis/visible.ts"; import { dictsFromSettings } from "../dicts.ts"; diff --git a/web/src/modules/jira/board/components/BoardCanvas.tsx b/web/src/modules/jira/board/components/BoardCanvas.tsx index 8ddd562..c90cca8 100644 --- a/web/src/modules/jira/board/components/BoardCanvas.tsx +++ b/web/src/modules/jira/board/components/BoardCanvas.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type PointerEvent, type ReactNode } from "react"; -import { isClosed } from "../../../../api/closed.ts"; +import { isClosed } from "../../api/closed.ts"; 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"; diff --git a/web/src/modules/jira/board/dicts.ts b/web/src/modules/jira/board/dicts.ts index a7a1a9c..101405f 100644 --- a/web/src/modules/jira/board/dicts.ts +++ b/web/src/modules/jira/board/dicts.ts @@ -1,11 +1,11 @@ -import type { HubSettings } from "../../../api/types.ts"; +import type { JiraSettings } from "../api/types.ts"; import type { Dicts } from "./types.ts"; // словари доски из настроек хаба: kind_labels (вид → метка) и link_types (логический тип → имя Jira) обращаются // в «метка → вид» и «имя Jira → логический тип»; пустой словарь — в missing (доска сообщит, чего не хватает) const invert = (o: Record | undefined) => Object.fromEntries(Object.entries(o ?? {}).map(([k, v]) => [v, k])); -export function dictsFromSettings(s: HubSettings): { dicts: Dicts; missing: string[] } { +export function dictsFromSettings(s: JiraSettings): { 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); diff --git a/web/src/modules/jira/board/layout/layout.test.ts b/web/src/modules/jira/board/layout/layout.test.ts index 4b5e488..d759692 100644 --- a/web/src/modules/jira/board/layout/layout.test.ts +++ b/web/src/modules/jira/board/layout/layout.test.ts @@ -1,7 +1,7 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import type { BoardSnapshot } from "../../../../api/types.ts"; +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"; diff --git a/web/src/modules/jira/board/model/fromHub.ts b/web/src/modules/jira/board/model/fromHub.ts index 97538d7..6adee88 100644 --- a/web/src/modules/jira/board/model/fromHub.ts +++ b/web/src/modules/jira/board/model/fromHub.ts @@ -1,4 +1,4 @@ -import type { BoardIssue, BoardSnapshot } from "../../../../api/types.ts"; +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"; diff --git a/web/src/modules/jira/board/model/model.test.ts b/web/src/modules/jira/board/model/model.test.ts index eb6bef3..3ff53c9 100644 --- a/web/src/modules/jira/board/model/model.test.ts +++ b/web/src/modules/jira/board/model/model.test.ts @@ -1,7 +1,7 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import type { BoardSnapshot } from "../../../../api/types.ts"; +import type { BoardSnapshot } from "../../api/types.ts"; import { canon } from "../../../../test/canon.ts"; import { dictsFromSettings } from "../dicts.ts"; import settings from "../__fixtures__/settings.json"; diff --git a/web/src/modules/jira/board/model/util.test.ts b/web/src/modules/jira/board/model/util.test.ts index cc0f629..b173e60 100644 --- a/web/src/modules/jira/board/model/util.test.ts +++ b/web/src/modules/jira/board/model/util.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { dayDiff, emptyModel, fmtDate, fmtStamp, notStarted, plural, shortName, sortableDate, unassigned, whoKey } from "./util.ts"; +import { dayDiff, emptyModel, fmtDate, notStarted, plural, shortName, sortableDate, unassigned, whoKey } from "./util.ts"; describe("util", () => { it("sortableDate приводит даты Jira к ГГГГ-ММ-ДД", () => { @@ -19,7 +19,7 @@ describe("util", () => { expect(dayDiff("2026-10-14", "2026-10-20")).toBe(6); expect(dayDiff("", "2026-10-20")).toBeNull(); }); - it("notStarted, unassigned, whoKey, fmtStamp, emptyModel", () => { + it("notStarted, unassigned, whoKey, emptyModel", () => { expect(notStarted("Backlog")).toBe(true); expect(notStarted("К выполнению")).toBe(true); expect(notStarted("В работе")).toBe(false); @@ -28,8 +28,6 @@ describe("util", () => { expect(unassigned("Иванов")).toBe(false); expect(whoKey(" Иванов ")).toBe("Иванов"); expect(whoKey("unassigned")).toBe(""); - expect(fmtStamp("не дата")).toBe("не дата"); - expect(fmtStamp("2026-10-14T09:05:00")).toMatch(/^14\.10 09:05$/); expect(emptyModel("пусто")).toMatchObject({ nodes: [], links: [], emptyText: "пусто", missing: 0 }); }); }); diff --git a/web/src/modules/jira/board/model/util.ts b/web/src/modules/jira/board/model/util.ts index b326acf..3f099a2 100644 --- a/web/src/modules/jira/board/model/util.ts +++ b/web/src/modules/jira/board/model/util.ts @@ -31,10 +31,6 @@ export function sortableDate(d: string): string { } 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); diff --git a/web/src/modules/jira/board/storage.test.ts b/web/src/modules/jira/board/storage.test.ts index 98b0419..9ebe8e6 100644 --- a/web/src/modules/jira/board/storage.test.ts +++ b/web/src/modules/jira/board/storage.test.ts @@ -1,7 +1,7 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { BoardSnapshot } from "../../../api/types.ts"; +import type { BoardSnapshot } from "../api/types.ts"; import { canon } from "../../../test/canon.ts"; import { computeVisible } from "./analysis/visible.ts"; import { dictsFromSettings } from "./dicts.ts"; diff --git a/web/src/modules/jira/board/useBoardSource.ts b/web/src/modules/jira/board/useBoardSource.ts index f4edb9f..bcac7fc 100644 --- a/web/src/modules/jira/board/useBoardSource.ts +++ b/web/src/modules/jira/board/useBoardSource.ts @@ -7,7 +7,8 @@ import { qk, useBoard } from "../api/queries.ts"; import { fromCsv } from "./model/fromCsv.ts"; import { fromHub } from "./model/fromHub.ts"; import { fromJson } from "./model/fromJson.ts"; -import { emptyModel, fmtStamp } from "./model/util.ts"; +import { emptyModel } from "./model/util.ts"; +import { fmtStamp } from "../../../ui/format.ts"; import type { BoardStatus } from "./status.ts"; import type { BoardModel, Dicts, JsonImport } from "./types.ts"; diff --git a/web/src/modules/jira/feed/EventList.tsx b/web/src/modules/jira/feed/EventList.tsx index 325a990..708f3a5 100644 --- a/web/src/modules/jira/feed/EventList.tsx +++ b/web/src/modules/jira/feed/EventList.tsx @@ -1,4 +1,4 @@ -import type { ChangeEvent } from "../../../api/types.ts"; +import type { ChangeEvent } from "../api/types.ts"; import { eventText, eventTime } from "./events.ts"; import "./feed.css"; diff --git a/web/src/modules/jira/feed/FeedPage.test.tsx b/web/src/modules/jira/feed/FeedPage.test.tsx index e08c8ab..423a356 100644 --- a/web/src/modules/jira/feed/FeedPage.test.tsx +++ b/web/src/modules/jira/feed/FeedPage.test.tsx @@ -7,7 +7,7 @@ import { focusManager } from "@tanstack/react-query"; import { act } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { App } from "../../../App.tsx"; -import type { ChangeEvent } from "../../../api/types.ts"; +import type { ChangeEvent } from "../api/types.ts"; import { setToken } from "../../../auth/token.ts"; import settings from "../board/__fixtures__/settings.json"; import { routes } from "../../../routes.tsx"; diff --git a/web/src/modules/jira/feed/FeedPage.tsx b/web/src/modules/jira/feed/FeedPage.tsx index 1a1f455..151319c 100644 --- a/web/src/modules/jira/feed/FeedPage.tsx +++ b/web/src/modules/jira/feed/FeedPage.tsx @@ -1,7 +1,7 @@ import { useMemo } from "react"; import { useSearchParams } from "react-router"; import { useChanges } from "../api/queries.ts"; -import type { ChangeKind } from "../../../api/types.ts"; +import type { ChangeKind } from "../api/types.ts"; import { IssueLink } from "../issues/IssueLink.tsx"; import { Badge, Button, Checkbox, Notice, PageHeader, Panel, Segmented } from "../../../ui/index.ts"; import { EventList } from "./EventList.tsx"; diff --git a/web/src/modules/jira/feed/events.test.ts b/web/src/modules/jira/feed/events.test.ts index cf25f55..eee4757 100644 --- a/web/src/modules/jira/feed/events.test.ts +++ b/web/src/modules/jira/feed/events.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import type { ChangeEvent } from "../../../api/types.ts"; +import type { ChangeEvent } from "../api/types.ts"; import { DEFAULT_KINDS, eventText, eventTime, groupByIssue, kindsParam, parseKinds, parsePeriod, periodRange } from "./events.ts"; const NOW = new Date(2026, 9, 22, 14, 5); diff --git a/web/src/modules/jira/feed/events.ts b/web/src/modules/jira/feed/events.ts index f8ee0c4..14a42d0 100644 --- a/web/src/modules/jira/feed/events.ts +++ b/web/src/modules/jira/feed/events.ts @@ -1,4 +1,4 @@ -import type { ChangeEvent, ChangeKind } from "../../../api/types.ts"; +import type { ChangeEvent, ChangeKind } from "../api/types.ts"; // период ленты: границы по местному времени браузера; «Сегодня» — по умолчанию и в адрес не пишется (spec web-app «Лента изменений») export type Period = "2h" | "today" | "yesterday" | "7d"; diff --git a/web/src/modules/jira/issues/IssueCard.test.tsx b/web/src/modules/jira/issues/IssueCard.test.tsx index 0bb4390..f1ba65f 100644 --- a/web/src/modules/jira/issues/IssueCard.test.tsx +++ b/web/src/modules/jira/issues/IssueCard.test.tsx @@ -5,7 +5,7 @@ import { createMemoryRouter, RouterProvider, useNavigate, useParams } from "reac import { beforeEach, describe, expect, it, vi } from "vitest"; import { qk } from "../api/queries.ts"; import { makeQueryClient } from "../../../api/queryClient.ts"; -import type { IssueAttachment, IssueDetail } from "../../../api/types.ts"; +import type { IssueAttachment, IssueDetail } from "../api/types.ts"; import { setToken } from "../../../auth/token.ts"; import baseSettings from "../board/__fixtures__/settings.json"; import { json, mockFetch } from "../../../test/fetchMock.ts"; @@ -50,7 +50,7 @@ describe("карточка задачи", () => { expect(within(head).getByText("В работе", { selector: ".ui-badge" })).toBeInTheDocument(); expect(head.querySelector(".badge.fact")).toBeNull(); // статуса по факту в карточке нет (hide-closed) expect(within(head).queryByText(/факт:/)).toBeNull(); - expect(within(head).getByTestId("issue-facts")).toHaveTextContent("Иванов Иван Иванович · срок 10.01 просрочено · AWPCA_Спринт 2 · эпик: Тестовый эпик"); + expect(within(head).getByTestId("issue-facts")).toHaveTextContent("Иванов Иван Иванович · срок 10.01.2020 просрочено · AWPCA_Спринт 2 · эпик: Тестовый эпик"); expect(within(head).getByText("просрочено")).toHaveClass("ui-badge--error"); expect(screen.getByRole("link", { name: "Открыть в Jira" })).toHaveAttribute("href", "https://sdlc.go.rshbank.ru/jira/browse/AURORA-9001"); }); @@ -389,7 +389,7 @@ describe("карточка задачи", () => { mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { planned_end: "2020-01-10", status: "Готово" }))); open("/issues/AURORA-9001"); const facts = await screen.findByTestId("issue-facts"); - expect(facts).toHaveTextContent("срок 10.01"); + expect(facts).toHaveTextContent("срок 10.01.2020"); expect(within(facts).queryByText("просрочено")).toBeNull(); }); diff --git a/web/src/modules/jira/issues/IssueCard.tsx b/web/src/modules/jira/issues/IssueCard.tsx index 797309d..ca7a084 100644 --- a/web/src/modules/jira/issues/IssueCard.tsx +++ b/web/src/modules/jira/issues/IssueCard.tsx @@ -1,11 +1,11 @@ import { type RefObject, useLayoutEffect, useRef, useState } from "react"; import { ApiError } from "../../../api/client.ts"; -import { useClosedStatuses } from "../../../api/closed.ts"; +import { useClosedStatuses } from "../api/closed.ts"; import { useSettings } from "../../../api/queries.ts"; import { useIssue } from "../api/queries.ts"; -import type { ChangeEvent, IssueAttachment, IssueComment, IssueDetail } from "../../../api/types.ts"; +import type { ChangeEvent, IssueAttachment, IssueComment, IssueDetail } from "../api/types.ts"; import { JIRA_URL } from "../board/constants.ts"; -import { fmtDate, fmtStamp } from "../board/model/util.ts"; +import { fmtDate, fmtStamp } from "../../../ui/format.ts"; import { EventList } from "../feed/EventList.tsx"; import { groupLinks, isOverdue, todayISO } from "./card.ts"; import { useCommentsHidden } from "./commentsPref.ts"; diff --git a/web/src/modules/jira/issues/IssueTable.tsx b/web/src/modules/jira/issues/IssueTable.tsx index 4434e9a..7a5be5e 100644 --- a/web/src/modules/jira/issues/IssueTable.tsx +++ b/web/src/modules/jira/issues/IssueTable.tsx @@ -1,6 +1,7 @@ import type { MouseEvent } from "react"; import { Badge, Panel } from "../../../ui/index.ts"; -import type { IssueRow } from "../../../api/types.ts"; +import type { IssueRow } from "../api/types.ts"; +import { fmtDate } from "../../../ui/format.ts"; import { isOverdue, todayISO } from "./card.ts"; import { IssueLink } from "./IssueLink.tsx"; import { dueOf } from "./options.ts"; @@ -29,7 +30,7 @@ export function IssueTable({ rows, epicNames, closed, onOpen }: { {i.summary} {i.status && {i.status}} {i.assignee} - {due} + {due ? fmtDate(due) : ""} {i.sprint} {i.epic_key ? epicNames[i.epic_key] ?? i.epic_key : ""} {i.work_kind} diff --git a/web/src/modules/jira/issues/IssuesPage.test.tsx b/web/src/modules/jira/issues/IssuesPage.test.tsx index ed371f5..c9a3451 100644 --- a/web/src/modules/jira/issues/IssuesPage.test.tsx +++ b/web/src/modules/jira/issues/IssuesPage.test.tsx @@ -346,7 +346,7 @@ describe("список задач", () => { ]); renderWithProviders(, { route: "/issues?closed=1", path: "/issues" }); await screen.findByRole("link", { name: "AURORA-9001" }); - const dueCell = (n: number) => within(screen.getByRole("row", { name: new RegExp(`AURORA-${9000 + n}`) })).getByText(/^(2000|2999)-01-01$/); + const dueCell = (n: number) => within(screen.getByRole("row", { name: new RegExp(`AURORA-${9000 + n}`) })).getByText(/^01\.01\.(2000|2999)$/); await waitFor(() => expect(dueCell(1)).toHaveClass("overdue")); expect(dueCell(2)).not.toHaveClass("overdue"); expect(dueCell(3)).not.toHaveClass("overdue"); diff --git a/web/src/modules/jira/issues/IssuesPage.tsx b/web/src/modules/jira/issues/IssuesPage.tsx index 6b6db78..02146f6 100644 --- a/web/src/modules/jira/issues/IssuesPage.tsx +++ b/web/src/modules/jira/issues/IssuesPage.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { useSearchParams } from "react-router"; -import { isClosed, useClosedStatuses } from "../../../api/closed.ts"; +import { isClosed, useClosedStatuses } from "../api/closed.ts"; import { useSettings } from "../../../api/queries.ts"; import { ISSUE_FILTER_NAMES, useBoard, useIssues, type IssueFilters as Filters } from "../api/queries.ts"; import { Button, Notice, PageHeader } from "../../../ui/index.ts"; diff --git a/web/src/modules/jira/issues/NoteForm.tsx b/web/src/modules/jira/issues/NoteForm.tsx index d25bed0..1a66171 100644 --- a/web/src/modules/jira/issues/NoteForm.tsx +++ b/web/src/modules/jira/issues/NoteForm.tsx @@ -5,7 +5,7 @@ import { ApiError } from "../../../api/client.ts"; import { useSettings } from "../../../api/queries.ts"; import { useSaveNote } from "../api/queries.ts"; import { Button, Field, Notice, Select, Textarea } from "../../../ui/index.ts"; -import { NOTE_FIELDS, type IssueDetail, type NoteField, type NoteFields } from "../../../api/types.ts"; +import { NOTE_FIELDS, type IssueDetail, type NoteField, type NoteFields } from "../api/types.ts"; type Values = Record; const valuesOf = (n: IssueDetail["note"]): Values => diff --git a/web/src/modules/jira/issues/Notes.tsx b/web/src/modules/jira/issues/Notes.tsx index 2457f1c..be4fbd9 100644 --- a/web/src/modules/jira/issues/Notes.tsx +++ b/web/src/modules/jira/issues/Notes.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; -import type { IssueDetail, NoteField } from "../../../api/types.ts"; +import type { IssueDetail, NoteField } from "../api/types.ts"; import { Button } from "../../../ui/index.ts"; -import { fmtStamp } from "../board/model/util.ts"; +import { fmtStamp } from "../../../ui/format.ts"; import { NoteForm } from "./NoteForm.tsx"; const ROWS: [NoteField, string][] = [["work_kind", "Вид работы"], ["done_text", "Сделано"], diff --git a/web/src/modules/jira/issues/attachments/AttachmentChips.tsx b/web/src/modules/jira/issues/attachments/AttachmentChips.tsx index 8dabde8..c9521c7 100644 --- a/web/src/modules/jira/issues/attachments/AttachmentChips.tsx +++ b/web/src/modules/jira/issues/attachments/AttachmentChips.tsx @@ -1,4 +1,4 @@ -import type { IssueAttachment } from "../../../../api/types.ts"; +import type { IssueAttachment } from "../../api/types.ts"; import { IconExternal, IconPaperclip } from "../../../../ui/index.ts"; import type { Download } from "./AttachmentList.tsx"; import { fmtSize, isTooBig, jiraAttachmentUrl, TOO_BIG_HINT } from "./format.ts"; diff --git a/web/src/modules/jira/issues/attachments/AttachmentList.tsx b/web/src/modules/jira/issues/attachments/AttachmentList.tsx index bf3fcfa..845c9ad 100644 --- a/web/src/modules/jira/issues/attachments/AttachmentList.tsx +++ b/web/src/modules/jira/issues/attachments/AttachmentList.tsx @@ -1,5 +1,5 @@ -import type { IssueAttachment } from "../../../../api/types.ts"; -import { fmtStamp } from "../../board/model/util.ts"; +import type { IssueAttachment } from "../../api/types.ts"; +import { fmtStamp } from "../../../../ui/format.ts"; import { Badge, Button, IconDownload, IconExternal, IconFile, IconImage, IconSheet } from "../../../../ui/index.ts"; import { fileKind, fmtSize, isTooBig, jiraAttachmentUrl, TOO_BIG_HINT } from "./format.ts"; import type { useAttachmentDownload } from "./useAttachmentDownload.ts"; diff --git a/web/src/modules/jira/issues/attachments/useAttachmentDownload.test.tsx b/web/src/modules/jira/issues/attachments/useAttachmentDownload.test.tsx index b7e4124..9c31694 100644 --- a/web/src/modules/jira/issues/attachments/useAttachmentDownload.test.tsx +++ b/web/src/modules/jira/issues/attachments/useAttachmentDownload.test.tsx @@ -3,7 +3,7 @@ import { act, renderHook } from "@testing-library/react"; import type { ReactNode } from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { makeQueryClient } from "../../../../api/queryClient.ts"; -import type { IssueAttachment } from "../../../../api/types.ts"; +import type { IssueAttachment } from "../../api/types.ts"; import { setToken } from "../../../../auth/token.ts"; import { json, mockFetch } from "../../../../test/fetchMock.ts"; import { useAttachmentDownload } from "./useAttachmentDownload.ts"; diff --git a/web/src/modules/jira/issues/attachments/useAttachmentDownload.ts b/web/src/modules/jira/issues/attachments/useAttachmentDownload.ts index 5437462..e56b230 100644 --- a/web/src/modules/jira/issues/attachments/useAttachmentDownload.ts +++ b/web/src/modules/jira/issues/attachments/useAttachmentDownload.ts @@ -3,7 +3,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { ApiError } from "../../../../api/client.ts"; import { api } from "../../../../api/client.ts"; import { qk } from "../../api/queries.ts"; -import type { AttachmentState, IssueAttachment } from "../../../../api/types.ts"; +import type { AttachmentState, IssueAttachment } from "../../api/types.ts"; export const POLL_MS = 2000; export interface DownloadStatus { busy: boolean; error: string | null } diff --git a/web/src/modules/jira/issues/card.ts b/web/src/modules/jira/issues/card.ts index 29b67a4..2b5f1ad 100644 --- a/web/src/modules/jira/issues/card.ts +++ b/web/src/modules/jira/issues/card.ts @@ -1,5 +1,5 @@ -import { isClosed } from "../../../api/closed.ts"; -import type { IssueDetail } from "../../../api/types.ts"; +import { isClosed } from "../api/closed.ts"; +import type { IssueDetail } from "../api/types.ts"; export type LinkGroupId = "depends" | "blocks" | "related" | "other"; export interface CardLink { key: string; summary: string | null; status: string | null; tag: string | null } diff --git a/web/src/modules/jira/issues/options.ts b/web/src/modules/jira/issues/options.ts index 4d0dee8..44dc2b9 100644 --- a/web/src/modules/jira/issues/options.ts +++ b/web/src/modules/jira/issues/options.ts @@ -1,4 +1,5 @@ -import type { BoardSnapshot, HubSettings } from "../../../api/types.ts"; +import type { JiraSettings } from "../api/types.ts"; +import type { BoardSnapshot } from "../api/types.ts"; export type Opt = { value: string; label: string }; const uniq = (xs: (string | null | undefined)[]) => [...new Set(xs.filter((x): x is string => !!x))].sort((a, b) => a.localeCompare(b, "ru")); @@ -16,7 +17,7 @@ export const withCurrent = (opts: Opt[], value: string | undefined, loaded: bool : [{ value, label: loaded ? `${value} (нет на доске)` : value }, ...opts]; // варианты выпадающих списков фильтров: из снимка /board и словарей настроек, без новых маршрутов API -export function filterOptions(snap: BoardSnapshot | undefined, s: HubSettings | undefined) { +export function filterOptions(snap: BoardSnapshot | undefined, s: JiraSettings | undefined) { const issues = snap?.issues ?? []; const names = epicNames(snap); const opt = (xs: string[]): Opt[] => xs.map((v) => ({ value: v, label: v })); diff --git a/web/src/modules/jira/issues/wiki/WikiText.fallback.test.tsx b/web/src/modules/jira/issues/wiki/WikiText.fallback.test.tsx index 8456914..ad15cee 100644 --- a/web/src/modules/jira/issues/wiki/WikiText.fallback.test.tsx +++ b/web/src/modules/jira/issues/wiki/WikiText.fallback.test.tsx @@ -7,7 +7,7 @@ vi.mock("./parse.ts", () => ({ parseWiki: () => { throw new Error("сбой ра it("ошибка разбора — блок показывается исходником", () => { const err = vi.spyOn(console, "error").mockImplementation(() => {}); const { container } = render(); - const pre = container.querySelector("pre.wiki-source"); + const pre = container.querySelector("pre.jira-markup-source"); expect(pre?.textContent).toBe("h3. A\nB"); err.mockRestore(); }); diff --git a/web/src/modules/jira/issues/wiki/WikiText.test.tsx b/web/src/modules/jira/issues/wiki/WikiText.test.tsx index 7217bd9..7abd1e0 100644 --- a/web/src/modules/jira/issues/wiki/WikiText.test.tsx +++ b/web/src/modules/jira/issues/wiki/WikiText.test.tsx @@ -19,7 +19,7 @@ describe("WikiText", () => { expect(screen.getByText("data_type").tagName).toBe("CODE"); expect(container.textContent).not.toContain("h3."); expect(container.textContent).not.toContain("{{"); - expect(container.firstElementChild).toHaveClass("wiki"); + expect(container.firstElementChild).toHaveClass("jira-markup"); }); it("таблица", () => { diff --git a/web/src/modules/jira/issues/wiki/WikiText.tsx b/web/src/modules/jira/issues/wiki/WikiText.tsx index fb04aff..61a6fe5 100644 --- a/web/src/modules/jira/issues/wiki/WikiText.tsx +++ b/web/src/modules/jira/issues/wiki/WikiText.tsx @@ -25,7 +25,7 @@ class Fallback extends Component<{ text: string; children: ReactNode }, { failed static getDerivedStateFromError() { return { failed: true }; } componentDidCatch(error: Error, info: ErrorInfo) { console.error("ошибка рендера разметки Jira", error, info.componentStack); } render() { - return this.state.failed ?
{this.props.text}
: this.props.children; + return this.state.failed ?
{this.props.text}
: this.props.children; } } @@ -34,7 +34,7 @@ interface Ctx { onAttachment?: (id: string) => void; attachmentView?: (id: strin function Rendered({ text, projectKey, attachments, onAttachment, attachmentView }: Props) { const ctx: Ctx = { onAttachment, attachmentView }; - return
{renderBlocks(parseWiki(text, { projectKey, attachments }), ctx)}
; + return
{renderBlocks(parseWiki(text, { projectKey, attachments }), ctx)}
; } function renderBlocks(blocks: Block[], ctx: Ctx): ReactNode[] { diff --git a/web/src/modules/jira/issues/wiki/wiki.css b/web/src/modules/jira/issues/wiki/wiki.css index b6b9699..1755178 100644 --- a/web/src/modules/jira/issues/wiki/wiki.css +++ b/web/src/modules/jira/issues/wiki/wiki.css @@ -1,22 +1,22 @@ -/* вики-разметка Jira в карточке (design wiki-render #9); селекторы div.wiki … сильнее общих .issue h3 и т. п. */ -div.wiki{line-height:1.5;overflow-wrap:anywhere;min-width:0} -div.wiki>:first-child{margin-top:0} -div.wiki>:last-child{margin-bottom:0} -div.wiki p{margin:0 0 6px} -div.wiki :is(h1,h2,h3,h4,h5,h6){font-weight:600;color:inherit;text-transform:none;letter-spacing:normal;line-height:1.3;margin:10px 0 4px} -div.wiki :is(h1,h2){font-size:15px} -div.wiki :is(h3,h4){font-size:14px} -div.wiki :is(h5,h6){font-size:13px} -div.wiki :is(ul,ol){margin:0 0 6px;padding-left:22px} -div.wiki li>:is(ul,ol){margin:2px 0 0} -div.wiki table{display:block;max-width:100%;min-width:0;overflow-x:auto;border-collapse:collapse;margin:0 0 6px;font-size:13px} -div.wiki :is(th,td){border:1px solid var(--color-border-default);padding:3px 8px;text-align:left;vertical-align:top;overflow-wrap:break-word} +/* вики-разметка Jira в карточке (design wiki-render #9); селекторы div.jira-markup … сильнее общих .issue h3 и т. п. */ +div.jira-markup{line-height:1.5;overflow-wrap:anywhere;min-width:0} +div.jira-markup>:first-child{margin-top:0} +div.jira-markup>:last-child{margin-bottom:0} +div.jira-markup p{margin:0 0 6px} +div.jira-markup :is(h1,h2,h3,h4,h5,h6){font-weight:600;color:inherit;text-transform:none;letter-spacing:normal;line-height:1.3;margin:10px 0 4px} +div.jira-markup :is(h1,h2){font-size:15px} +div.jira-markup :is(h3,h4){font-size:14px} +div.jira-markup :is(h5,h6){font-size:13px} +div.jira-markup :is(ul,ol){margin:0 0 6px;padding-left:22px} +div.jira-markup li>:is(ul,ol){margin:2px 0 0} +div.jira-markup table{display:block;max-width:100%;min-width:0;overflow-x:auto;border-collapse:collapse;margin:0 0 6px;font-size:13px} +div.jira-markup :is(th,td){border:1px solid var(--color-border-default);padding:3px 8px;text-align:left;vertical-align:top;overflow-wrap:break-word} /* глобальные table/th из board.css (min-width 520px, прописные 11px) здесь не действуют (I-1) */ -div.wiki th{background:var(--color-bg-hover);font-weight:600;font-size:inherit;text-transform:none;letter-spacing:normal;color:inherit} -div.wiki code,div.wiki pre{font-family:var(--font-mono);font-size:12.5px} -div.wiki code{background:var(--color-bg-hover);border:1px solid var(--color-border-default);border-radius:var(--radius-xs);padding:0 3px} -div.wiki pre{background:var(--color-bg-hover);border:1px solid var(--color-border-default);border-radius:var(--radius-sm);padding:6px 8px;margin:0 0 6px;overflow-x:auto;white-space:pre;overflow-wrap:normal} -div.wiki blockquote{margin:0 0 6px;padding:0 0 0 10px;border-left:3px solid var(--color-border-default);color:var(--color-text-secondary)} -div.wiki hr{border:0;border-top:1px solid var(--color-border-default);margin:8px 0} -div.wiki .mention{color:var(--color-action-primary)} -pre.wiki-source{white-space:pre-wrap;font:inherit;margin:0} +div.jira-markup th{background:var(--color-bg-hover);font-weight:600;font-size:inherit;text-transform:none;letter-spacing:normal;color:inherit} +div.jira-markup code,div.jira-markup pre{font-family:var(--font-mono);font-size:12.5px} +div.jira-markup code{background:var(--color-bg-hover);border:1px solid var(--color-border-default);border-radius:var(--radius-xs);padding:0 3px} +div.jira-markup pre{background:var(--color-bg-hover);border:1px solid var(--color-border-default);border-radius:var(--radius-sm);padding:6px 8px;margin:0 0 6px;overflow-x:auto;white-space:pre;overflow-wrap:normal} +div.jira-markup blockquote{margin:0 0 6px;padding:0 0 0 10px;border-left:3px solid var(--color-border-default);color:var(--color-text-secondary)} +div.jira-markup hr{border:0;border-top:1px solid var(--color-border-default);margin:8px 0} +div.jira-markup .mention{color:var(--color-action-primary)} +pre.jira-markup-source{white-space:pre-wrap;font:inherit;margin:0} diff --git a/web/src/modules/jira/refresh/DataStatus.tsx b/web/src/modules/jira/refresh/DataStatus.tsx index e435677..e21c971 100644 --- a/web/src/modules/jira/refresh/DataStatus.tsx +++ b/web/src/modules/jira/refresh/DataStatus.tsx @@ -1,5 +1,5 @@ import { useBoard } from "../api/queries.ts"; -import { fmtStamp } from "../board/model/util.ts"; +import { fmtStamp } from "../../../ui/format.ts"; import { Button, IconButton, IconRefresh } from "../../../ui/index.ts"; import { useJiraRefresh } from "./refresh.ts"; import { refreshText } from "./refreshStatus.ts"; diff --git a/web/src/modules/modules.test.tsx b/web/src/modules/modules.test.tsx index c5c8e5c..d90428d 100644 --- a/web/src/modules/modules.test.tsx +++ b/web/src/modules/modules.test.tsx @@ -1,4 +1,6 @@ -import { describe, expect, it } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router"; +import { describe, expect, it, vi } from "vitest"; import { PAGE_ROUTES } from "../shell/pages.tsx"; import { MODULES } from "./index.ts"; @@ -17,4 +19,32 @@ describe("сборка оболочки из модулей", () => { const names = MODULES.map((m) => m.name); expect(new Set(names).size).toBe(names.length); }); + + it("имена модулей уникальны: сообщение называет дубль", () => { + const names = MODULES.map((m) => m.name); + const dups = names.filter((n, i) => names.indexOf(n) !== i); + expect(dups, `повтор имени модуля: ${dups.join(", ")}`).toEqual([]); + }); + + it("адреса пунктов панели уникальны: сообщение называет дубль", () => { + const paths = MODULES.flatMap((m) => m.nav).map((n) => n.to); + const dups = paths.filter((p, i) => paths.indexOf(p) !== i); + expect(dups, `повтор адреса пункта: ${dups.join(", ")}`).toEqual([]); + }); +}); + +describe("NotFound при повторе адреса", () => { + it("ключ ссылки уникален: дубль адреса не даёт предупреждения React", async () => { + vi.resetModules(); + vi.doMock("../modules/index.ts", () => ({ + MODULES: [{ name: "a", nav: [{ to: "/x", label: "A", icon: null }, { to: "/x", label: "B", icon: null }] }], + })); + const { NotFound: NF } = await import("../shell/NotFound.tsx"); + const err = vi.spyOn(console, "error").mockImplementation(() => {}); + render(); + expect(screen.getAllByRole("link")).toHaveLength(2); + expect(err.mock.calls.flat().join(" ")).not.toMatch(/same key/); + err.mockRestore(); + vi.doUnmock("../modules/index.ts"); + }); }); diff --git a/web/src/modules/wiki/PageView.tsx b/web/src/modules/wiki/PageView.tsx index b5298b8..ace93e8 100644 --- a/web/src/modules/wiki/PageView.tsx +++ b/web/src/modules/wiki/PageView.tsx @@ -3,7 +3,7 @@ import Markdown, { type Components } from "react-markdown"; import { Link } from "react-router"; import remarkGfm from "remark-gfm"; import { Notice, IconExternal, IconPaperclip } from "../../ui/index.ts"; -import { fmtStamp } from "../jira/board/model/util.ts"; +import { fmtStamp } from "../../ui/format.ts"; import { useWikiPage } from "./api/queries.ts"; import { useWikiDownload } from "./download.ts"; import { wikiUrlTransform } from "./urlTransform.ts"; diff --git a/web/src/modules/wiki/SyncStatus.tsx b/web/src/modules/wiki/SyncStatus.tsx index 329d46b..ffe777d 100644 --- a/web/src/modules/wiki/SyncStatus.tsx +++ b/web/src/modules/wiki/SyncStatus.tsx @@ -1,5 +1,5 @@ import { useProjects } from "../../api/queries.ts"; -import { fmtStamp } from "../jira/board/model/util.ts"; +import { fmtStamp } from "../../ui/format.ts"; import { useWikiStatus } from "./api/queries.ts"; // ход синхронизации вики текущего проекта в боковой панели; запрос — только у проекта с корнем (по списку проектов) diff --git a/web/src/modules/ModuleProviders.tsx b/web/src/shell/ModuleProviders.tsx similarity index 88% rename from web/src/modules/ModuleProviders.tsx rename to web/src/shell/ModuleProviders.tsx index 21a197b..4485241 100644 --- a/web/src/modules/ModuleProviders.tsx +++ b/web/src/shell/ModuleProviders.tsx @@ -1,5 +1,5 @@ import type { ReactNode } from "react"; -import { MODULES } from "./index.ts"; +import { MODULES } from "../modules/index.ts"; // провайдеры модулей вкладываются в порядке списка: первый — внешний export function ModuleProviders({ children }: { children: ReactNode }) { diff --git a/web/src/shell/NotFound.tsx b/web/src/shell/NotFound.tsx index e2378bb..7a98bc6 100644 --- a/web/src/shell/NotFound.tsx +++ b/web/src/shell/NotFound.tsx @@ -9,7 +9,7 @@ export function NotFound() { return (
Страница не найдена}> - Разделы приложения: {NAV.map((n, i) => {i > 0 && ", "}{n.label})}. + Разделы приложения: {NAV.map((n, i) => {i > 0 && ", "}{n.label})}.
); diff --git a/web/src/test/issueFixtures.ts b/web/src/test/issueFixtures.ts index 5e13c9d..0fd3e92 100644 --- a/web/src/test/issueFixtures.ts +++ b/web/src/test/issueFixtures.ts @@ -1,7 +1,7 @@ -import type { IssueDetail, Note } from "../api/types.ts"; +import type { IssueDetail, Note } from "../modules/jira/api/types.ts"; export const note = (over: Partial = {}): Note => ({ - key: "AURORA-9001", fact_status: "В работе", work_kind: "разработка", done_text: "схема", rest_text: "API", + key: "AURORA-9001", work_kind: "разработка", done_text: "схема", rest_text: "API", owner_note: null, updated_at: "2026-10-14T09:00:00+00:00", updated_by: "chat", ...over, }); diff --git a/web/src/ui/format.test.ts b/web/src/ui/format.test.ts new file mode 100644 index 0000000..96e35c0 --- /dev/null +++ b/web/src/ui/format.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { fmtDate, fmtStamp } from "./format.ts"; + +describe("форматтеры дат ядра", () => { + it("fmtDate: текущий год — ДД.ММ, другой — ДД.ММ.ГГГГ, null — тире", () => { + expect(fmtDate("2026-10-19", new Date("2026-10-24"))).toBe("19.10"); + expect(fmtDate("2025-12-31", new Date("2026-10-24"))).toBe("31.12.2025"); + expect(fmtDate(null)).toBe("—"); + }); + + it("fmtStamp: ДД.ММ ЧЧ:ММ, не дата — как есть, null — тире", () => { + expect(fmtStamp("2026-10-24T11:07:00")).toBe("24.10 11:07"); + expect(fmtStamp("не дата")).toBe("не дата"); + expect(fmtStamp(null)).toBe("—"); + }); +}); diff --git a/web/src/ui/format.ts b/web/src/ui/format.ts new file mode 100644 index 0000000..acaf5b3 --- /dev/null +++ b/web/src/ui/format.ts @@ -0,0 +1,18 @@ +// форматтеры дат ядра: общие для оболочки и модулей + +const pad = (n: number): string => String(n).padStart(2, "0"); + +// «ДД.ММ ЧЧ:ММ» в локальном времени; не дата — как есть, null — «—» +export function fmtStamp(iso: string | null): string { + if (iso === null) return "—"; + const d = new Date(iso); + return isNaN(d.getTime()) ? String(iso) : `${pad(d.getDate())}.${pad(d.getMonth() + 1)} ${pad(d.getHours())}:${pad(d.getMinutes())}`; +} + +// «ДД.ММ» для текущего года, «ДД.ММ.ГГГГ» для другого; null — «—»; не ГГГГ-ММ-ДД — как есть +export function fmtDate(iso: string | null, now: Date = new Date()): string { + if (iso === null) return "—"; + const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(iso); + if (!m) return iso; + return Number(m[1]) === now.getFullYear() ? `${m[3]}.${m[2]}` : `${m[3]}.${m[2]}.${m[1]}`; +} -- 2.47.2 From 112720ab1d97745ebcf37b80a13778c8a994989f Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 13:31:32 +0400 Subject: [PATCH 06/19] =?UTF-8?q?feat(web-modules):=20=D1=81=D0=BE=D1=81?= =?UTF-8?q?=D1=82=D0=BE=D1=8F=D0=BD=D0=B8=D0=B5=20=D0=BC=D0=BE=D0=B4=D1=83?= =?UTF-8?q?=D0=BB=D1=8F=20=D0=B2=20=D0=BA=D0=BE=D0=BD=D1=82=D1=80=D0=B0?= =?UTF-8?q?=D0=BA=D1=82=D0=B5,=20=D1=82=D1=80=D0=B5=D0=BA=D0=B5=D1=80=20?= =?UTF-8?q?=D0=BF=D0=B0=D0=BA=D0=B5=D1=82=D0=BE=D0=B2=20=D0=B8=20=D0=B0?= =?UTF-8?q?=D0=B3=D0=B5=D0=BD=D1=82=D1=8B=20=D0=B2=20=D1=8F=D0=B4=D1=80?= =?UTF-8?q?=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- openspec/changes/web-modules/design.md | 8 +- web/src/modules/index.ts | 7 - web/src/modules/jira/board/BoardPage.test.tsx | 2 +- .../jira/board/useBoardSource.test.tsx | 8 +- web/src/modules/jira/feed/FeedPage.test.tsx | 2 +- web/src/modules/jira/index.tsx | 16 +- web/src/modules/jira/refresh/DataStatus.tsx | 48 -- .../modules/jira/refresh/RefreshProvider.tsx | 174 ------- .../modules/jira/refresh/SidebarStatus.tsx | 15 - web/src/modules/jira/refresh/refresh.test.tsx | 253 ---------- web/src/modules/jira/refresh/refresh.ts | 20 - .../jira/refresh/refreshStatus.test.ts | 20 - web/src/modules/jira/status.ts | 20 + web/src/modules/types.ts | 22 +- web/src/modules/wiki/SyncStatus.test.tsx | 44 -- web/src/modules/wiki/SyncStatus.tsx | 28 -- web/src/modules/wiki/WikiPage.tsx | 9 +- web/src/modules/wiki/api/queries.ts | 11 +- web/src/modules/wiki/index.tsx | 5 +- web/src/modules/wiki/status.ts | 24 + web/src/modules/wiki/wiki.css | 6 - web/src/projects/NewProjectDialog.test.tsx | 2 +- web/src/projects/projects.test.tsx | 10 +- web/src/shell/Layout.tsx | 15 +- web/src/shell/Sidebar.test.tsx | 36 +- web/src/shell/Sidebar.tsx | 7 +- web/src/shell/activity/ActivityProvider.tsx | 229 +++++++++ web/src/shell/activity/StatusBlock.tsx | 74 +++ web/src/shell/activity/activity.test.tsx | 455 ++++++++++++++++++ web/src/shell/activity/activity.ts | 38 ++ .../activity/events.ts} | 13 +- web/src/shell/shell.css | 3 + web/src/test/batchHub.ts | 18 +- web/src/test/render.tsx | 4 +- 34 files changed, 952 insertions(+), 694 deletions(-) delete mode 100644 web/src/modules/jira/refresh/DataStatus.tsx delete mode 100644 web/src/modules/jira/refresh/RefreshProvider.tsx delete mode 100644 web/src/modules/jira/refresh/SidebarStatus.tsx delete mode 100644 web/src/modules/jira/refresh/refresh.test.tsx delete mode 100644 web/src/modules/jira/refresh/refresh.ts delete mode 100644 web/src/modules/jira/refresh/refreshStatus.test.ts create mode 100644 web/src/modules/jira/status.ts delete mode 100644 web/src/modules/wiki/SyncStatus.test.tsx delete mode 100644 web/src/modules/wiki/SyncStatus.tsx create mode 100644 web/src/modules/wiki/status.ts create mode 100644 web/src/shell/activity/ActivityProvider.tsx create mode 100644 web/src/shell/activity/StatusBlock.tsx create mode 100644 web/src/shell/activity/activity.test.tsx create mode 100644 web/src/shell/activity/activity.ts rename web/src/{modules/jira/refresh/refreshStatus.ts => shell/activity/events.ts} (66%) diff --git a/openspec/changes/web-modules/design.md b/openspec/changes/web-modules/design.md index 88bc8c5..21bf79a 100644 --- a/openspec/changes/web-modules/design.md +++ b/openspec/changes/web-modules/design.md @@ -125,4 +125,10 @@ type HubModule = { | 21 | 24.10.2026 | Task 2: `ModuleProviders.tsx` перенесён из `modules/` в `shell/` (импортирует только `modules/index.ts`) | тест-страж границы допускает из ядра лишь `modules/index.ts` и `modules/types.ts`; `App.tsx` импортировал `modules/ModuleProviders.tsx` | вернуть файл и ослабить страж | | 22 | 24.10.2026 | Task 2: `fmtDate` доски (`board/model/util.ts`, принимает не-ISO даты Jira, год не показывает) остаётся в модуле; в ядре `ui/format.ts` — свой `fmtDate`, таблица и карточка задач берут его (даты другого года — `ДД.ММ.ГГГГ`); `fmtStamp(null)` → «—» | задача требует форматтеры в ядре; доска сравнивает строки дат Jira в разных локалях | свести две функции в одну | | 23 | 24.10.2026 | Task 2: словари jira-настроек — тип `JiraSettings extends HubSettings` в `modules/jira/api/types.ts`; `HubSettings` ядра — только `project_key` и индексная подпись | страж запрещает термины jira в ядре | вернуть словари в `HubSettings` | - +| 24 | 24.10.2026 | Task 3: `ActivityProvider` стоит в `Layout` (внутри `ProjectContext`), а не в `App.tsx`; «проект создан» — `trackBatch(batchId)` ядра (модуль берётся из `Batch.module`, запасной путь — `refresh.isOwn`) вместо `useOnProjectCreated` модуля | `project` провайдера берётся из `ProjectContext`, он создаётся в `Layout`; модуль не называется в ядре | перенести провайдер выше и передавать проект иначе | +| 25 | 24.10.2026 | Task 3: подхват пакетов — только при монтировании, смене проекта, раз в 60 с и после 409; прежний подхват «после загрузки снимка доски» (`skipPickup`, T10.1/T4.1) убран | ядро не знает ключей данных модуля; правила подхвата заданы спекой; счётчик `skipPickup` больше не нужен | вернуть подписку на кэш по ключу, объявляемому модулем | +| 26 | 24.10.2026 | Task 3: состояние на модуль — хук-трекер на модуль (`useTracker`, вызывается по константному списку `TRACKED`) и `useQuery` пакета в каждом, а не общий `useQueries` | перенос логики остановки опроса без изменений; `useQueries` потребовал бы переписать эффект завершения | заменить на `useQueries` | +| 27 | 24.10.2026 | Task 3: `start` берёт агента свежим `fetchQuery(["agents"], staleTime 0)`, а не из кэша; ошибка этого запроса — событие `hub-down`/`error`, как у прежнего `GET /agent` | сохраняет поведение прежних сценариев (hub-down, 401 на предпроверке) | брать из кэша `useAgents()` | +| 28 | 24.10.2026 | Task 3: wiki `refresh.start` приводит ответ `{batch_id}` к `{id}`; `track()` перехватывает отслеживание, если текущий пакет не «свой» (раньше — только если ничего не отслеживалось) | контракт ядра единый `{id}`; пакет нового проекта важнее чужого | отдельная форма ответа / прежняя проверка | +| 29 | 24.10.2026 | Task 3: сводка `useActivitySummary`: error > offline > busy > online | ошибка и офлайн агента важнее, чем «идёт пакет» | поменять порядок | +| 30 | 24.10.2026 | Task 3: временный `shell/activity/StatusBlock.tsx` (строки модулей и строка событий узкого экрана) вместо `SidebarStatus`/`TopbarNotice` модулей; Task 4 заменяет его; страница вики берёт действие из трекера, уведомление об ошибке постановки (`syncError`) убрано — ошибка видна событием модуля | оболочка должна работать и набор быть зелёным до Task 4 | вернуть Notice на странице вики | diff --git a/web/src/modules/index.ts b/web/src/modules/index.ts index 6d213ef..71c941b 100644 --- a/web/src/modules/index.ts +++ b/web/src/modules/index.ts @@ -6,15 +6,8 @@ import type { HubModule, Overlay } from "./types.ts"; export const MODULES: readonly HubModule[] = [jiraModule, wikiModule]; const OVERLAY_HOOKS = MODULES.flatMap((m) => m.useOverlay ?? []); -const CREATED_HOOKS = MODULES.flatMap((m) => m.useOnProjectCreated ?? []); // окна всех модулей; их число и порядок не меняются между отрисовками export function useOverlays(): Overlay[] { return OVERLAY_HOOKS.map((use) => use()); } - -// обработчик «проект создан»: вызывает обработчики всех модулей -export function useOnProjectCreated(): (batchId: number) => void { - const handlers = CREATED_HOOKS.map((use) => use()); - return (batchId) => handlers.forEach((h) => h(batchId)); -} diff --git a/web/src/modules/jira/board/BoardPage.test.tsx b/web/src/modules/jira/board/BoardPage.test.tsx index 6ab56f2..79826e5 100644 --- a/web/src/modules/jira/board/BoardPage.test.tsx +++ b/web/src/modules/jira/board/BoardPage.test.tsx @@ -20,7 +20,7 @@ function hub(over: Record Response> = {}) { 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 === "GET /agents") return json(200, { agents: [] }); 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" }); diff --git a/web/src/modules/jira/board/useBoardSource.test.tsx b/web/src/modules/jira/board/useBoardSource.test.tsx index 2d9f246..4f22be0 100644 --- a/web/src/modules/jira/board/useBoardSource.test.tsx +++ b/web/src/modules/jira/board/useBoardSource.test.tsx @@ -7,8 +7,8 @@ import { makeQueryClient } from "../../../api/queryClient.ts"; import { setToken } from "../../../auth/token.ts"; import { json } from "../../../test/fetchMock.ts"; import { batch, deferred, fixture as fx, hub, SNAP } from "../../../test/batchHub.ts"; -import { useJiraRefresh } from "../refresh/refresh.ts"; -import { RefreshProvider } from "../refresh/RefreshProvider.tsx"; +import { ActivityProvider } from "../../../shell/activity/ActivityProvider.tsx"; +import { useModuleActivity } from "../../../shell/activity/activity.ts"; import { dictsFromSettings } from "./dicts.ts"; import settings from "./__fixtures__/settings.json"; import { useBoardSource } from "./useBoardSource.ts"; @@ -16,8 +16,8 @@ import { useBoardSource } from "./useBoardSource.ts"; const CSV = fx("jira-export.csv"); const { dicts } = dictsFromSettings(settings); // доска под провайдером обновления из Jira (как в приложении); refresh — состояние оболочки -const hook = (client = makeQueryClient()) => ({ client, ...renderHook(() => ({ ...useBoardSource(dicts, "AWPCA"), refresh: useJiraRefresh() }), - { wrapper: ({ children }: { children: ReactNode }) => {children} }) }); +const hook = (client = makeQueryClient()) => ({ client, ...renderHook(() => ({ ...useBoardSource(dicts, "AWPCA"), refresh: useModuleActivity("jira") }), + { wrapper: ({ children }: { children: ReactNode }) => {children} }) }); describe("useBoardSource", () => { beforeEach(() => setToken("tok")); diff --git a/web/src/modules/jira/feed/FeedPage.test.tsx b/web/src/modules/jira/feed/FeedPage.test.tsx index 423a356..3ac3009 100644 --- a/web/src/modules/jira/feed/FeedPage.test.tsx +++ b/web/src/modules/jira/feed/FeedPage.test.tsx @@ -38,7 +38,7 @@ function hub(changes: () => Changes = () => ({ changes: [] })) { if (k === "GET /board") return json(200, SNAP); if (k === "GET /issues") return json(200, { issues: [] }); if (k === "GET /batches") return json(200, { batches: [] }); - if (k === "GET /agent") return json(200, { online: true, last_seen_at: null }); + if (k === "GET /agents") return json(200, { agents: [] }); if (k.startsWith("GET /issues/")) return json(200, issue(decodeURIComponent(url.pathname.split("/").pop()!))); return json(404, { error: "not_found" }); }); diff --git a/web/src/modules/jira/index.tsx b/web/src/modules/jira/index.tsx index bcefab3..b8dd4b3 100644 --- a/web/src/modules/jira/index.tsx +++ b/web/src/modules/jira/index.tsx @@ -6,18 +6,11 @@ import { BoardPage } from "./board/BoardPage.tsx"; import { FeedPage } from "./feed/FeedPage.tsx"; import { useIssueOverlay } from "./issues/overlay.tsx"; import { IssuesPage } from "./issues/IssuesPage.tsx"; -import { useJiraRefresh } from "./refresh/refresh.ts"; -import { RefreshProvider } from "./refresh/RefreshProvider.tsx"; -import { SidebarStatus, TopbarNotice } from "./refresh/SidebarStatus.tsx"; - -// после создания проекта пакет обновления, поставленный хабом, показывается в плашке данных -function useOnProjectCreated() { - const refresh = useJiraRefresh(); - return (batchId: number) => refresh.track(batchId); -} +import { jiraStatus } from "./status.ts"; export const jiraModule: HubModule = { name: "jira", + title: "Jira", pages: [ { path: "/", element: }, { path: "/board", element: {(p) => } }, @@ -29,10 +22,7 @@ export const jiraModule: HubModule = { { to: "/issues", label: "Задачи", icon: , padded: true }, { to: "/feed", label: "Лента", icon: , padded: true, keepSearch: true }, ], - Provider: RefreshProvider, - SidebarStatus, - TopbarNotice, + status: jiraStatus, useOverlay: useIssueOverlay, projectFields: [{ name: "board_id", label: "Номер доски Jira", hint: "scrum или kanban определится сам", kind: "int" }], - useOnProjectCreated, }; diff --git a/web/src/modules/jira/refresh/DataStatus.tsx b/web/src/modules/jira/refresh/DataStatus.tsx deleted file mode 100644 index e21c971..0000000 --- a/web/src/modules/jira/refresh/DataStatus.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { useBoard } from "../api/queries.ts"; -import { fmtStamp } from "../../../ui/format.ts"; -import { Button, IconButton, IconRefresh } from "../../../ui/index.ts"; -import { useJiraRefresh } from "./refresh.ts"; -import { refreshText } from "./refreshStatus.ts"; - -const HINT = "Поставить в хаб пакет «обновление по базовой JQL» и дождаться его: агент перечитает задачи из Jira"; - -// состояние данных: агент, время последнего полного обновления, кнопка «Обновить из Jira» и ход пакета; -// compact — свёрнутая боковая панель: кнопка-значок с точкой агента, тексты в подсказке (#69, design 3) -export function DataStatus({ project, compact = false }: { project: string | null; compact?: boolean }) { - const refresh = useJiraRefresh(); - const board = useBoard(project); - const ls = board.data?.last_sync ?? null; - const agent = refresh.agent; - const rt = refresh.event ? refreshText(refresh.event) : null; - const seen = agent?.last_seen_at ? `последний выход на связь: ${fmtStamp(agent.last_seen_at)}` : "выхода на связь ещё не было"; - const agentText = agent ? (agent.online ? "агент на связи" : "агент офлайн") : null; - const syncText = board.data ? (ls ? `данные на ${fmtStamp(ls.finished_at)}` : "полного обновления не было") : null; - const event = {rt?.text}; - if (compact) { - const title = ["Обновить из Jira", agentText, syncText, rt?.text].filter(Boolean).join(" · "); - return ( -
-
- ); - } - return ( -
- {agent && ( - - - )} - {syncText && {syncText}} - - {event} -
- ); -} diff --git a/web/src/modules/jira/refresh/RefreshProvider.tsx b/web/src/modules/jira/refresh/RefreshProvider.tsx deleted file mode 100644 index d7be62b..0000000 --- a/web/src/modules/jira/refresh/RefreshProvider.tsx +++ /dev/null @@ -1,174 +0,0 @@ -// владелец состояния «Обновить из Jira»: один опрос пакета на всё приложение. Правила — прежние (hub#20): -// одна кнопка и один опрос (M-2), подхват незавершённого пакета после любой загрузки снимка (M-3), остановка опроса -// (M-4, T10.2), только пакеты с первой командой refresh (T10.6) -import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; -import { api, ApiError, AuthError, dropTokenOn, HubDownError } from "../../../api/client.ts"; -import { qk } from "../api/queries.ts"; -import type { AgentView, Batch, CreatedBatch } from "../../../api/types.ts"; -import { RefreshContext, type JiraRefresh } from "./refresh.ts"; -import { DONE_VISIBLE_MS, MAX_POLL_ERRORS, POLL_MS, type RefreshEvent } from "./refreshStatus.ts"; - -interface Active { id: number; agentOffline: boolean } - -const terminal = (b: Batch | undefined) => !!b && (b.status === "done" || b.status === "cancelled"); - -export function RefreshProvider({ children, pollMs = POLL_MS }: { children: ReactNode; pollMs?: number }) { - const qc = useQueryClient(); - const [event, setEvent] = useState(null); - const [active, setActive] = useState(null); - const [starting, setStarting] = useState(false); - const startingRef = useRef(false); - const failures = useRef(0); - const activeRef = useRef(active); - useEffect(() => { activeRef.current = active; }, [active]); - // T10.1: сколько ближайших загрузок снимка — перечитывание после своего пакета (пакет уже завершён, подхватывать нечего) - const skipPickup = useRef(0); - const picking = useRef(false); - - const agent = useQuery({ - queryKey: ["agent"], refetchInterval: 60_000, refetchIntervalInBackground: true, - queryFn: () => api.get("/agent"), - }); - - const report = useCallback((e: unknown) => { - if (e instanceof AuthError) dropTokenOn(e); - else if (e instanceof HubDownError) setEvent({ kind: "hub-down" }); - else setEvent({ kind: "error", message: e instanceof Error ? e.message : String(e) }); - }, []); - - // M-3 #20: подхватить незавершённый пакет обновления от viewer - const pickup = useCallback(() => { - if (activeRef.current || startingRef.current || picking.current) return; - picking.current = true; - (async () => { - const r = await api.get<{ batches: Batch[] }>("/batches", { limit: 20 }); - const candidates = r.batches.filter((x) => x.author === "viewer" && (x.status === "queued" || x.status === "running")); - for (const b of candidates) { // M-12: пакет wiki_sync не заслоняет более старый refresh - const full = await api.get(`/batches/${b.id}`); // T10.6: в списке нет команд — тип смотрим в пакете (R-2) - if (activeRef.current || startingRef.current) return; - if (full.commands?.[0]?.type !== "refresh") continue; - failures.current = 0; - setActive((cur) => cur ?? { id: b.id, agentOffline: false }); - return; - } - })().catch(() => { /* не критично: подхватим при следующей загрузке */ }).finally(() => { picking.current = false; }); - }, []); - - useEffect(() => { - pickup(); - return qc.getQueryCache().subscribe((ev) => { - if (ev.type !== "updated" || ev.action.type !== "success" || ev.query.queryKey[0] !== "board") return; - if (skipPickup.current > 0) { skipPickup.current -= 1; return; } - pickup(); - }); - }, [qc, pickup]); - - // M-2, M-4: один опрос пакета; остановка на завершении, любой 4xx (T10.2), 401 и после MAX_POLL_ERRORS ошибок подряд - const batch = useQuery({ - queryKey: ["batch", active?.id ?? 0], - enabled: active !== null, - gcTime: 0, - staleTime: 0, - queryFn: async () => { - try { - const b = await api.get(`/batches/${active!.id}`); - failures.current = 0; - return b; - } catch (e) { - if (e instanceof HubDownError) failures.current += 1; - throw e; - } - }, - refetchInterval: (q) => { - const e = q.state.error; - if (terminal(q.state.data)) return false; - if (e instanceof AuthError || e instanceof ApiError) return false; // любая 4xx не лечится повтором - if (failures.current >= MAX_POLL_ERRORS) return false; - return pollMs; - }, - refetchIntervalInBackground: true, - }); - - const finish = useCallback((id: number) => { - failures.current = 0; - setActive(null); - qc.removeQueries({ queryKey: ["batch", id] }); - // перечитывание активных снимков досок — после своего пакета, подхват не нужен; показанный файл не заменяется (M-1) - skipPickup.current = qc.getQueryCache().findAll({ queryKey: qk.boardAll, type: "active" }).length; - // T4.1: перечитывание могло упасть (успеха нет, счётчик не уменьшился) — по его завершению счётчик гасится в любом случае - void qc.invalidateQueries({ queryKey: qk.boardAll }).finally(() => { skipPickup.current = 0; }); - for (const key of [["issues"], ["issue"], ["changes"], qk.projects]) void qc.invalidateQueries({ queryKey: key }); - }, [qc]); - - useEffect(() => { - if (!active) return; - const b = batch.data, e = batch.error; - if (b && b.id === active.id && !batch.isError) { - if (terminal(b)) { - const cmd = b.commands?.[0]; - setEvent(cmd?.status === "failed" ? { kind: "batch-failed", id: b.id, error: cmd.error || "ошибка агента" } - : b.status === "cancelled" || cmd?.status === "cancelled" || cmd?.status === "skipped" ? { kind: "batch-cancelled", id: b.id } - : { kind: "batch-done", id: b.id }); - finish(b.id); - return; - } - setEvent({ kind: "batch", id: b.id, phase: b.status === "queued" ? "queued" : "running", - remaining: b.remaining_seconds, agentOffline: active.agentOffline, retrying: false }); - return; - } - if (e instanceof ApiError) { - setEvent({ kind: "batch-lost", id: active.id, reason: e.status === 404 ? "пакета нет в хабе" : e.detail ?? e.message }); - finish(active.id); - } else if (e instanceof HubDownError) { - if (failures.current >= MAX_POLL_ERRORS) { - setEvent({ kind: "batch-lost", id: active.id, reason: "хаб не отвечает" }); - finish(active.id); - } else { - setEvent((cur) => (cur?.kind === "batch" ? { ...cur, retrying: true } : cur)); - } - } - }, [batch.data, batch.error, batch.isError, batch.errorUpdatedAt, batch.dataUpdatedAt, active, finish]); - - // «пакет выполнен» гаснет сам - useEffect(() => { - if (event?.kind !== "batch-done") return; - const t = setTimeout(() => setEvent((cur) => (cur === event ? null : cur)), DONE_VISIBLE_MS); - return () => clearTimeout(t); - }, [event]); - - const start = useCallback(async () => { - if (startingRef.current || activeRef.current) return; - startingRef.current = true; - setStarting(true); - try { - const a = await api.get("/agent"); - const b = await api.post("/refresh", { full: true, author: "viewer" }); - failures.current = 0; - const next = { id: b.id, agentOffline: !a.online }; - activeRef.current = next; - setActive(next); - setEvent({ kind: "batch", id: b.id, phase: "queued", remaining: b.estimate_seconds, agentOffline: !a.online, retrying: false }); - } catch (e) { - report(e); - } finally { - startingRef.current = false; - setStarting(false); - } - }, [report]); - - const track = useCallback((id: number) => { - if (activeRef.current) return; - failures.current = 0; - const next = { id, agentOffline: false }; - activeRef.current = next; - setActive(next); - setEvent({ kind: "batch", id, phase: "queued", remaining: 0, agentOffline: false, retrying: false }); - }, []); - - const value = useMemo(() => ({ - event, busy: starting || active !== null, agent: agent.data ?? null, start, track, - }), [event, starting, active, agent.data, start, track]); - - return {children}; -} diff --git a/web/src/modules/jira/refresh/SidebarStatus.tsx b/web/src/modules/jira/refresh/SidebarStatus.tsx deleted file mode 100644 index 2437a75..0000000 --- a/web/src/modules/jira/refresh/SidebarStatus.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { DataStatus } from "./DataStatus.tsx"; -import { useJiraRefresh } from "./refresh.ts"; -import { refreshText } from "./refreshStatus.ts"; - -// плашка данных и кнопка «Обновить из Jira» в боковой панели -export function SidebarStatus({ project, compact }: { project: string | null; compact: boolean }) { - return ; -} - -// I-1: на телефоне подсказок нет — событие пакета строкой под верхней панелью -export function TopbarNotice() { - const refresh = useJiraRefresh(); - const rt = refresh.event ? refreshText(refresh.event) : null; - return rt ? : null; -} diff --git a/web/src/modules/jira/refresh/refresh.test.tsx b/web/src/modules/jira/refresh/refresh.test.tsx deleted file mode 100644 index f117583..0000000 --- a/web/src/modules/jira/refresh/refresh.test.tsx +++ /dev/null @@ -1,253 +0,0 @@ -import { QueryClientProvider } from "@tanstack/react-query"; -import { act, render, renderHook, waitFor } from "@testing-library/react"; -import type { ReactNode } from "react"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { useBoard, useIssues } from "../api/queries.ts"; -import { makeQueryClient } from "../../../api/queryClient.ts"; -import { getToken, setToken } from "../../../auth/token.ts"; -import { json } from "../../../test/fetchMock.ts"; -import { batch, deferred, hub, SNAP, sleep } from "../../../test/batchHub.ts"; -import { useJiraRefresh, type JiraRefresh } from "./refresh.ts"; -import { RefreshProvider } from "./RefreshProvider.tsx"; - -const wrap = (client = makeQueryClient()) => ({ children }: { children: ReactNode }) => - {children}; -const refreshHook = (client = makeQueryClient()) => ({ client, ...renderHook(() => useJiraRefresh(), { wrapper: wrap(client) }) }); - -describe("RefreshProvider", () => { - beforeEach(() => setToken("tok")); - afterEach(() => { vi.useRealTimers(); }); - - it("вне провайдера — понятная ошибка", () => { - const spy = vi.spyOn(console, "error").mockImplementation(() => {}); - expect(() => renderHook(() => useJiraRefresh())).toThrow(/RefreshProvider/); - spy.mockRestore(); - }); - - it("M-2: двойное нажатие — один пакет и один опрос за раз; по завершении board перечитан, список пакетов — нет", async () => { - let polls = 0; - const h = hub({ "GET /batches/:id": () => (++polls < 4 ? batch("running") : batch("done")) }); - const { result } = renderHook(() => ({ r: useJiraRefresh(), b: useBoard("AWPCA") }), { wrapper: wrap() }); - await waitFor(() => expect(result.current.b.data).toBeDefined()); - await waitFor(() => expect(h.count("GET /batches")).toBeGreaterThanOrEqual(1)); - const listed = h.count("GET /batches"); - await act(async () => { void result.current.r.start(); await result.current.r.start(); }); - expect(h.count("POST /refresh")).toBe(1); - expect(result.current.r.busy).toBe(true); - await waitFor(() => expect(result.current.r.event).toEqual({ kind: "batch-done", id: 7 })); - expect(result.current.r.busy).toBe(false); - expect(h.maxInFlight("GET /batches/7")).toBe(1); - await waitFor(() => expect(h.count("GET /board")).toBe(2)); // по завершении снимок перечитан - await sleep(30); - expect(h.count("GET /batches")).toBe(listed); // T10.1: после своего пакета список пакетов не перечитывается - }); - - it("M-3: незавершённый пакет viewer подхватывается после загрузки снимка (в том числе повторной)", async () => { - let listed: unknown[] = []; - const h = hub({ "GET /batches": () => json(200, { batches: listed }), "GET /batches/:id": () => batch("running") }); - const { result } = renderHook(() => ({ r: useJiraRefresh(), b: useBoard("AWPCA") }), { wrapper: wrap() }); - await waitFor(() => expect(h.count("GET /batches")).toBeGreaterThanOrEqual(1)); - expect(result.current.r.busy).toBe(false); - listed = [{ id: 7, author: "viewer", status: "running" }, { id: 6, author: "chat", status: "running" }]; - await act(async () => { await result.current.b.refetch(); }); - await waitFor(() => expect(result.current.r.busy).toBe(true)); - await waitFor(() => expect(result.current.r.event).toMatchObject({ kind: "batch", id: 7, phase: "running" })); - }); - - it("M-3: пакет подхватывается при монтировании провайдера", async () => { - hub({ "GET /batches": () => json(200, { batches: [{ id: 7, author: "viewer", status: "queued" }] }), "GET /batches/:id": () => batch("running") }); - const { result } = refreshHook(); - await waitFor(() => expect(result.current.event).toMatchObject({ kind: "batch", id: 7 })); - expect(result.current.busy).toBe(true); - }); - - it("M-4: 404 на опросе — опрос остановлен, кнопка доступна", async () => { - const h = hub({ "GET /batches/:id": () => json(404, { error: "not_found", detail: "batch" }) }); - const { result } = refreshHook(); - await act(() => result.current.start()); - await waitFor(() => expect(result.current.event).toMatchObject({ kind: "batch-lost", id: 7, reason: "пакета нет в хабе" })); - expect(result.current.busy).toBe(false); - const n = h.count("GET /batches/7"); - await sleep(50); - expect(h.count("GET /batches/7")).toBe(n); - }); - - for (const status of [400, 422]) { - it(`T10.2: ${status} на опросе — опрос остановлен, batch-lost с detail, busy сброшен`, async () => { - const h = hub({ "GET /batches/:id": () => json(status, { error: "bad_request", detail: "плохой пакет" }) }); - const { result } = refreshHook(); - await act(() => result.current.start()); - await waitFor(() => expect(result.current.event).toMatchObject({ kind: "batch-lost", id: 7, reason: expect.stringContaining("плохой пакет") })); - expect(result.current.busy).toBe(false); - const n = h.count("GET /batches/7"); - await sleep(50); - expect(h.count("GET /batches/7")).toBe(n); - }); - } - - it("M-4: 5 ошибок «хаб не отвечает» подряд — опрос остановлен", async () => { - const h = hub({ "GET /batches/:id": () => { throw new TypeError("Failed to fetch"); } }); - const { result } = refreshHook(); - await act(() => result.current.start()); - await waitFor(() => expect(result.current.event).toMatchObject({ kind: "batch-lost", reason: "хаб не отвечает" })); - expect(h.count("GET /batches/7")).toBe(5); - await sleep(50); - expect(h.count("GET /batches/7")).toBe(5); - expect(result.current.busy).toBe(false); - }); - - it("пакет с ошибкой команды — текст ошибки; агент офлайн — предупреждение в событии", async () => { - let polls = 0; - hub({ - "GET /agent": () => json(200, { online: false, last_seen_at: null, warning: null }), - "GET /batches/:id": () => (++polls < 3 ? batch("running") : batch("done", { status: "failed", error: "Jira 500" })), - }); - const { result } = refreshHook(); - await act(() => result.current.start()); - await waitFor(() => expect(result.current.event).toMatchObject({ kind: "batch", agentOffline: true })); - await waitFor(() => expect(result.current.event).toEqual({ kind: "batch-failed", id: 7, error: "Jira 500" })); - }); - - it("«Обновить из Jira» при недоступном хабе — событие hub-down", async () => { - hub({ "GET /agent": () => { throw new TypeError("Failed to fetch"); } }); - const { result } = refreshHook(); - await act(() => result.current.start()); - expect(result.current.event).toEqual({ kind: "hub-down" }); - expect(result.current.busy).toBe(false); - }); - - it("T10.6: незавершённый пакет viewer без refresh не подхватывается", async () => { - const h = hub({ - "GET /batches": () => json(200, { batches: [{ id: 7, author: "viewer", status: "running" }] }), - "GET /batches/:id": () => batch("running", { type: "ensure_fields" }), - }); - const { result } = refreshHook(); - await waitFor(() => expect(h.count("GET /batches/7")).toBe(1)); - await sleep(50); - expect(result.current.busy).toBe(false); - expect(h.count("GET /batches/7")).toBe(1); // только проверка типа, опроса нет - }); - - it("M-12: пакет wiki_sync viewer не заслоняет более старый refresh", async () => { - hub({ - "GET /batches": () => json(200, { batches: [{ id: 9, author: "viewer", status: "queued" }, { id: 7, author: "viewer", status: "running" }] }), - "GET /batches/:id": (url) => batch("running", { type: url.pathname.endsWith("/9") ? "wiki_sync" : "refresh" }), - }); - const { result } = refreshHook(); - await waitFor(() => expect(result.current.event).toMatchObject({ kind: "batch", id: 7 })); - expect(result.current.busy).toBe(true); - }); - - it("401 на запуске пакета с текущим токеном — токен сброшен", async () => { - hub({ "GET /agent": () => json(401, { error: "unauthorized" }) }); - const { result } = refreshHook(); - await act(() => result.current.start()); - expect(getToken()).toBe(""); - }); - - it("запоздавший 401 прежнему токену не сбрасывает новый токен", async () => { - const slow = deferred(); - hub({ "GET /agent": () => slow.promise }); - const { result } = refreshHook(); - let run!: Promise; - act(() => { run = result.current.start(); }); - setToken("new-tok"); - slow.resolve(json(401, { error: "unauthorized" })); - await act(() => run); - expect(getToken()).toBe("new-tok"); - }); - - it("T4.1: упавшее перечитывание снимка после своего пакета не гасит подхват следующей независимой загрузки", async () => { - let boardFails = false, listed: unknown[] = []; - const h = hub({ - "GET /board": () => (boardFails ? json(500, { error: "boom" }) : json(200, SNAP)), - "GET /batches": () => json(200, { batches: listed }), - "GET /batches/:id": (url) => (url.pathname.endsWith("/8") ? json(200, { id: 8, author: "viewer", status: "running", remaining_seconds: 3, counts: {}, commands: [{ type: "refresh", status: "running" }] }) : batch("done")), - }); - const { result } = renderHook(() => ({ r: useJiraRefresh(), b: useBoard("AWPCA") }), { wrapper: wrap() }); - await waitFor(() => expect(result.current.b.data).toBeDefined()); - await waitFor(() => expect(h.count("GET /batches")).toBeGreaterThanOrEqual(1)); - boardFails = true; - await act(() => result.current.r.start()); - await waitFor(() => expect(result.current.r.event).toEqual({ kind: "batch-done", id: 7 })); - await waitFor(() => expect(h.count("GET /board")).toBe(2)); // перечитывание после пакета упало - await sleep(30); - boardFails = false; - listed = [{ id: 8, author: "viewer", status: "running" }]; - await act(async () => { await result.current.b.refetch(); }); - await waitFor(() => expect(result.current.r.event).toMatchObject({ kind: "batch", id: 8 })); - }); - - it("по завершении инвалидируются board, issues, issue, projects", async () => { - let polls = 0; - const h = hub({ "GET /batches/:id": () => (++polls < 2 ? batch("running") : batch("done")) }); - const { result } = renderHook(() => ({ r: useJiraRefresh(), b: useBoard("AWPCA"), i: useIssues({}) }), { wrapper: wrap() }); - await waitFor(() => expect(result.current.b.data).toBeDefined()); - await waitFor(() => expect(result.current.i.data).toBeDefined()); - await act(() => result.current.r.start()); - await waitFor(() => expect(result.current.r.event).toMatchObject({ kind: "batch-done" })); - await waitFor(() => expect(h.count("GET /board")).toBe(2)); - await waitFor(() => expect(h.count("GET /issues")).toBe(2)); - }); - - it("инвалидируются ключи board, issues, issue, projects, changes (шпион)", async () => { - const client = makeQueryClient(); - const spy = vi.spyOn(client, "invalidateQueries"); - hub({ "GET /batches/:id": () => batch("done") }); - const { result } = refreshHook(client); - await act(() => result.current.start()); - await waitFor(() => expect(result.current.event).toEqual({ kind: "batch-done", id: 7 })); - const keys = spy.mock.calls.map(([f]) => (f as { queryKey: unknown[] }).queryKey[0]); - expect(keys).toEqual(expect.arrayContaining(["board", "issues", "issue", "projects", "changes"])); - }); - - it("track(9) опрашивает пакет 9 без POST /refresh", async () => { - const h = hub({ "GET /batches/:id": () => json(200, { id: 9, title: "x", author: "viewer", created_at: "", cancelled_at: null, status: "done", counts: {}, remaining_seconds: 0, commands: [{ id: 1, seq: 1, type: "refresh", error: null, finished_at: null, status: "done" }] }) }); - const { result } = refreshHook(); - await waitFor(() => expect(h.count("GET /batches")).toBe(1)); - act(() => result.current.track(9)); - expect(result.current.busy).toBe(true); - await waitFor(() => expect(result.current.event).toEqual({ kind: "batch-done", id: 9 })); - expect(h.count("POST /refresh")).toBe(0); - expect(h.count("GET /batches/9")).toBeGreaterThanOrEqual(1); - }); - - it("batch-done исчезает через 10 с", async () => { - hub({ "GET /batches/:id": () => batch("done") }); - vi.useFakeTimers({ shouldAdvanceTime: true }); // время идёт и само (опрос, waitFor), но сдвигается и вручную - const { result } = refreshHook(); - await act(() => result.current.start()); - await waitFor(() => expect(result.current.event).toEqual({ kind: "batch-done", id: 7 })); - await act(async () => { vi.advanceTimersByTime(9_800); }); - expect(result.current.event).toEqual({ kind: "batch-done", id: 7 }); - await act(async () => { vi.advanceTimersByTime(200); }); - expect(result.current.event).toBeNull(); - }); - - it("переход между страницами во время пакета: один POST, один опрос, busy до завершения; по завершении board перечитан", async () => { - let finished = false; - const h = hub({ "GET /batches/:id": () => (finished ? batch("done") : batch("running")) }); - const client = makeQueryClient(); - let r!: JiraRefresh; - const Probe = () => { r = useJiraRefresh(); return null; }; - const Page = () => { useBoard("AWPCA"); return null; }; // «страница»: монтируется и размонтируется, провайдер — один - const ui = (page: boolean) => ( - {page && }); - const view = render(ui(true)); - await waitFor(() => expect(h.count("GET /board")).toBe(1)); - await act(() => r.start()); - view.rerender(ui(false)); - expect(r.busy).toBe(true); - view.rerender(ui(true)); - await waitFor(() => expect(h.count("GET /board")).toBeGreaterThanOrEqual(1)); - await sleep(40); - expect(h.count("POST /refresh")).toBe(1); - expect(h.maxInFlight("GET /batches/7")).toBe(1); - expect(r.busy).toBe(true); - const boards = h.count("GET /board"); - finished = true; - await waitFor(() => expect(r.busy).toBe(false)); - expect(r.event).toMatchObject({ kind: "batch-done" }); - await waitFor(() => expect(h.count("GET /board")).toBe(boards + 1)); - }); -}); diff --git a/web/src/modules/jira/refresh/refresh.ts b/web/src/modules/jira/refresh/refresh.ts deleted file mode 100644 index 5cfae20..0000000 --- a/web/src/modules/jira/refresh/refresh.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { createContext, useContext } from "react"; -import type { AgentView } from "../../../api/types.ts"; -import type { RefreshEvent } from "./refreshStatus.ts"; - -// обновление из Jira: состояние живёт в оболочке (RefreshProvider), страницы и верхняя панель только читают его -export interface JiraRefresh { - event: RefreshEvent | null; - busy: boolean; // запрос /refresh в пути или пакет выполняется - agent: AgentView | null; - start(): Promise; - track(batchId: number): void; // опрашивать уже поставленный пакет (форма «Новый проект») -} - -export const RefreshContext = createContext(null); - -export function useJiraRefresh(): JiraRefresh { - const v = useContext(RefreshContext); - if (!v) throw new Error("useJiraRefresh вызван вне RefreshProvider: оберните приложение в "); - return v; -} diff --git a/web/src/modules/jira/refresh/refreshStatus.test.ts b/web/src/modules/jira/refresh/refreshStatus.test.ts deleted file mode 100644 index a4ed1a8..0000000 --- a/web/src/modules/jira/refresh/refreshStatus.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { refreshText } from "./refreshStatus.ts"; - -describe("refreshText", () => { - it("пакет: офлайн, очередь, остаток, повтор", () => { - expect(refreshText({ kind: "batch", id: 7, phase: "queued", remaining: 12, agentOffline: true, retrying: false })) - .toEqual({ text: "агент офлайн — пакет выполнится, когда он выйдет на связь; пакет #7 в очереди, осталось ~12 с…", tone: "warning" }); - expect(refreshText({ kind: "batch", id: 7, phase: "running", remaining: 0, agentOffline: false, retrying: true })) - .toEqual({ text: "пакет #7 выполняется… (хаб не отвечает, повтор через 3 с)", tone: "warning" }); - }); - it("исходы и ошибки", () => { - expect(refreshText({ kind: "batch-done", id: 7 })).toEqual({ text: "пакет #7 выполнен", tone: "success" }); - expect(refreshText({ kind: "batch-failed", id: 7, error: "429" })).toEqual({ text: "пакет #7 не выполнен: 429", tone: "error" }); - expect(refreshText({ kind: "batch-cancelled", id: 7 })).toEqual({ text: "пакет #7 отменён", tone: "warning" }); - expect(refreshText({ kind: "batch-lost", id: 7, reason: "пакета нет в хабе" }).text) - .toBe("пакет #7: пакета нет в хабе — опрос остановлен, «Обновить из Jira» снова доступна"); - expect(refreshText({ kind: "hub-down" })).toEqual({ text: "хаб не отвечает — пакет не поставлен", tone: "error" }); - expect(refreshText({ kind: "error", message: "boom" })).toEqual({ text: "boom", tone: "error" }); - }); -}); diff --git a/web/src/modules/jira/status.ts b/web/src/modules/jira/status.ts new file mode 100644 index 0000000..96c0200 --- /dev/null +++ b/web/src/modules/jira/status.ts @@ -0,0 +1,20 @@ +import { api } from "../../api/client.ts"; +import type { CreatedBatch } from "../../api/types.ts"; +import type { ModuleStatus } from "../types.ts"; +import { qk } from "./api/queries.ts"; + +// состояние модуля jira (контракт ModuleStatus): свежесть — по всем проектам, обновление — полное по базовой JQL +export const jiraStatus: ModuleStatus = { + agent: "jira", + scope: "all", + freshness: (p) => ({ available: true, at: (p.jira_synced_at as string | null | undefined) ?? null, error: null }), + refresh: { + label: "Обновить из Jira", + start: () => api.post("/refresh", { full: true, author: "viewer" }), + isOwn: (b) => b.commands?.[0]?.type === "refresh", + }, + // перечитывание активных снимков досок — после своего пакета; показанный файл не заменяется (M-1) + invalidate: (qc) => { + for (const key of [qk.boardAll, ["issues"], ["issue"], ["changes"]]) void qc.invalidateQueries({ queryKey: key }); + }, +}; diff --git a/web/src/modules/types.ts b/web/src/modules/types.ts index 0c1bf81..315fcfc 100644 --- a/web/src/modules/types.ts +++ b/web/src/modules/types.ts @@ -1,5 +1,7 @@ +import type { QueryClient } from "@tanstack/react-query"; import type { ComponentType, ReactNode } from "react"; import type { Location, RouteObject } from "react-router"; +import type { Batch, Project } from "../api/types.ts"; export type NavItem = { to: string; label: string; icon: ReactNode; @@ -12,16 +14,28 @@ export type Overlay = { pageLocation: Location | null; element: ReactNode }; // поле окна «Новый проект», которое вносит модуль: пустое значение в тело запроса не попадает, int уходит числом export type ProjectFieldDef = { name: string; label: string; hint: string; kind: "int" | "text" }; +// состояние модуля — данные; рисует и ведёт его ядро (shell/activity, design web-modules «Контракт веб-модуля») +export type ModuleStatus = { + agent?: string; // имя модуля агента в GET /agents + scope: "all" | "project"; // охват свежести и обновления + freshness(p: Project): { available: boolean; at: string | null; error: string | null }; + refresh?: { + label: string; // «Обновить из Jira», «Обновить вики» + start(project: string): Promise<{ id: number; estimate_seconds?: number }>; // 409 → ApiError, ядро подхватит идущий + isOwn(b: Batch): boolean; // пакет этого действия (по commands[0].type) + }; + invalidate(qc: QueryClient, project: string | null): void; // что перечитать после пакета модуля +}; + // описание веб-модуля (design hub-modules 10): оболочка берёт из него страницы, пункты панели, состояние данных, окна export type HubModule = { name: string; + title: string; // название для показа: «Jira», «Вики» pages: RouteObject[]; // без "*" — NotFound добавляет оболочка последним nav: NavItem[]; Provider?: ComponentType<{ children: ReactNode }>; - // плашка данных и кнопка обновления в боковой панели; compact — свёрнутая панель или строка узкого экрана - SidebarStatus?: ComponentType<{ project: string | null; compact: boolean }>; - TopbarNotice?: ComponentType; // строка события под верхней панелью узкого экрана + status?: ModuleStatus; + Summary?: ComponentType<{ project: Project }>; // карточка модуля на «Обзоре» useOverlay?: () => Overlay; // вызывается оболочкой в постоянном порядке модулей projectFields?: ProjectFieldDef[]; // поля проекта модуля: окно создания собирает их у всех модулей - useOnProjectCreated?: () => (batchId: number) => void; }; diff --git a/web/src/modules/wiki/SyncStatus.test.tsx b/web/src/modules/wiki/SyncStatus.test.tsx deleted file mode 100644 index be543b8..0000000 --- a/web/src/modules/wiki/SyncStatus.test.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { QueryClientProvider } from "@tanstack/react-query"; -import { act, render, screen } from "@testing-library/react"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { makeQueryClient } from "../../api/queryClient.ts"; -import { setToken } from "../../auth/token.ts"; -import { json, mockFetch } from "../../test/fetchMock.ts"; -import { POLL_MS } from "./api/queries.ts"; -import { SidebarStatus } from "./SyncStatus.tsx"; - -const tick = (ms: number) => act(async () => { await vi.advanceTimersByTimeAsync(ms); }); -const base = { root: "DOC-1", root_title: "К", synced_at: "2026-10-20T10:00:00+00:00", pages: 3, error: null }; - -describe("SidebarStatus вики", () => { - beforeEach(() => { setToken("tok"); vi.useFakeTimers(); }); - afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals(); }); - - it("опрос раз в 3 с, пока пакет идёт; при терминальном статусе опрос прекращается", async () => { - let calls = 0; - mockFetch((_m, url) => { - if (url.pathname === "/projects") return json(200, { projects: [{ label: "AWPCA", name: "A", created_at: "", wiki_root: "DOC-1" }] }); - calls++; - return json(200, { ...base, batch: { id: 5, status: calls < 3 ? "running" : "done" } }); - }); - render(); - await tick(10); // сначала список проектов, затем статус - expect(screen.getByRole("status")).toHaveTextContent("пакет #5 выполняется"); - expect(calls).toBe(1); - await tick(POLL_MS); - expect(calls).toBe(2); - await tick(POLL_MS); - expect(calls).toBe(3); // третий ответ — done - expect(screen.queryByRole("status")).toBeNull(); - expect(screen.getByText(/^Вики на/)).toBeInTheDocument(); - await tick(5 * POLL_MS); - expect(calls).toBe(3); - }); - - it("проект без корня: запросов статуса нет", async () => { - const f = mockFetch((_m, url) => url.pathname === "/projects" ? json(200, { projects: [{ label: "P", name: "P", created_at: "", wiki_root: null }] }) : json(409, {})); - render(); - await tick(0); - expect(f.mock.calls.some(([u]) => String(u).startsWith("/wiki/status"))).toBe(false); - }); -}); diff --git a/web/src/modules/wiki/SyncStatus.tsx b/web/src/modules/wiki/SyncStatus.tsx deleted file mode 100644 index ffe777d..0000000 --- a/web/src/modules/wiki/SyncStatus.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { useProjects } from "../../api/queries.ts"; -import { fmtStamp } from "../../ui/format.ts"; -import { useWikiStatus } from "./api/queries.ts"; - -// ход синхронизации вики текущего проекта в боковой панели; запрос — только у проекта с корнем (по списку проектов) -export function SidebarStatus({ project, compact }: { project: string | null; compact: boolean }) { - const projects = useProjects(); - const hasRoot = !!project && !!projects.data?.find((p) => p.label === project)?.wiki_root; - const status = useWikiStatus(project ?? "", hasRoot); - const s = status.data; - if (!hasRoot || !s) return null; - let text: string, tone: "info" | "error" | "success" | "plain" = "plain"; - if (s.batch && s.batch.status !== "done" && s.batch.status !== "cancelled") { - text = `Вики: пакет #${s.batch.id} ${s.batch.status === "queued" ? "в очереди" : "выполняется"}…`; tone = "info"; - } else if (s.error) { - text = `Вики: синхронизация не удалась — ${s.error}`; tone = "error"; - } else if (s.synced_at) { - text = `Вики на ${fmtStamp(s.synced_at)}`; - } else { - text = "Вики ещё не синхронизирована"; - } - return ( -
- {text} - {compact && tone !== "plain" &&
- ); -} diff --git a/web/src/modules/wiki/WikiPage.tsx b/web/src/modules/wiki/WikiPage.tsx index cb24796..7098890 100644 --- a/web/src/modules/wiki/WikiPage.tsx +++ b/web/src/modules/wiki/WikiPage.tsx @@ -2,7 +2,8 @@ import { useCallback, useEffect, useState } from "react"; import { useNavigate, useSearchParams } from "react-router"; import { ApiError } from "../../api/client.ts"; import { Button, IconRefresh, Notice, PageHeader, Panel } from "../../ui/index.ts"; -import { useWikiSearch, useWikiStatus, useWikiSync, useWikiTree } from "./api/queries.ts"; +import { useModuleActivity } from "../../shell/activity/activity.ts"; +import { useWikiSearch, useWikiStatus, useWikiTree } from "./api/queries.ts"; import { PageView } from "./PageView.tsx"; import { RootForm } from "./RootForm.tsx"; import { SearchBox, SearchResults } from "./Search.tsx"; @@ -22,7 +23,7 @@ export function WikiPage({ project }: { project: string }) { const noRoot = status.error instanceof ApiError && status.error.error === "no_root"; const synced = !!status.data && (status.data.synced_at !== null || status.data.pages > 0); const tree = useWikiTree(project, synced); - const sync = useWikiSync(project); + const sync = useModuleActivity("wiki"); const [q, setQ] = useState(""); const [dq, setDq] = useState(""); @@ -39,13 +40,11 @@ export function WikiPage({ project }: { project: string }) { const s = status.data; const running = !!s.batch && s.batch.status !== "done" && s.batch.status !== "cancelled"; - const syncError = sync.error instanceof ApiError && sync.error.status === 409 ? null : sync.error?.message; return (
} loading={sync.isPending || running} onClick={() => sync.mutate()}>Обновить вики} /> + actions={} /> {s.error && {s.error}} - {syncError && {syncError}} {!synced ? ( {running ? "Вики синхронизируется…" : "Вики ещё не синхронизирована. Нажмите «Обновить вики»."} ) : ( diff --git a/web/src/modules/wiki/api/queries.ts b/web/src/modules/wiki/api/queries.ts index a387b28..0b02400 100644 --- a/web/src/modules/wiki/api/queries.ts +++ b/web/src/modules/wiki/api/queries.ts @@ -1,4 +1,4 @@ -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useEffect, useRef } from "react"; import { api } from "../../../api/client.ts"; @@ -62,12 +62,3 @@ export const useWikiSearch = (project: string, q: string) => queryKey: wikiQk.search(project, q), enabled: q !== "", queryFn: () => api.get<{ results: WikiHit[] }>("/wiki/search", { project, q, limit: SEARCH_LIMIT }).then((r) => r.results), }); - -// «Обновить вики»: пакет ставит хаб; 409 — синхронизация уже идёт, статус перечитывается и показывает её пакет -export function useWikiSync(project: string) { - const qc = useQueryClient(); - return useMutation({ - mutationFn: () => api.post<{ batch_id: number }>("/wiki/sync", { project, author: "viewer" }), - onSettled: () => qc.invalidateQueries({ queryKey: wikiQk.status(project) }), - }); -} diff --git a/web/src/modules/wiki/index.tsx b/web/src/modules/wiki/index.tsx index 264a9d6..735108c 100644 --- a/web/src/modules/wiki/index.tsx +++ b/web/src/modules/wiki/index.tsx @@ -1,13 +1,14 @@ import { ProjectGate } from "../../projects/ProjectGate.tsx"; import { IconBook } from "../../ui/index.ts"; import type { HubModule } from "../types.ts"; -import { SidebarStatus } from "./SyncStatus.tsx"; +import { wikiStatus } from "./status.ts"; import { WikiPage } from "./WikiPage.tsx"; export const wikiModule: HubModule = { name: "wiki", + title: "Вики", pages: [{ path: "/wiki", element: {(p) => } }], nav: [{ to: "/wiki", label: "Вики", icon: , padded: true }], - SidebarStatus, + status: wikiStatus, projectFields: [{ name: "wiki_root", label: "Корень вики", hint: "DOC-… или ссылка на страницу Eva", kind: "text" }], }; diff --git a/web/src/modules/wiki/status.ts b/web/src/modules/wiki/status.ts new file mode 100644 index 0000000..6d66e26 --- /dev/null +++ b/web/src/modules/wiki/status.ts @@ -0,0 +1,24 @@ +import { api } from "../../api/client.ts"; +import type { ModuleStatus } from "../types.ts"; +import { wikiQk } from "./api/queries.ts"; + +// состояние модуля wiki (контракт ModuleStatus): данные есть только у проекта с корнем вики, обновление — проектное +export const wikiStatus: ModuleStatus = { + agent: "wiki", + scope: "project", + freshness: (p) => ({ + available: !!p.wiki_root, + at: (p.wiki_synced_at as string | null | undefined) ?? null, + error: (p.wiki_sync_error as string | null | undefined) ?? null, + }), + refresh: { + label: "Обновить вики", + // хаб отвечает {batch_id}; 409 (синхронизация уже идёт) — ApiError, ядро подхватит идущий пакет + start: (project) => api.post<{ batch_id: number }>("/wiki/sync", { project, author: "viewer" }).then((r) => ({ id: r.batch_id })), + isOwn: (b) => b.commands?.[0]?.type === "wiki_sync", + }, + invalidate: (qc, project) => { + if (project === null) return; + for (const key of ["tree", "page", "search"]) void qc.invalidateQueries({ queryKey: [...wikiQk.project(project), key] }); + }, +}; diff --git a/web/src/modules/wiki/wiki.css b/web/src/modules/wiki/wiki.css index a051ba6..e96adc5 100644 --- a/web/src/modules/wiki/wiki.css +++ b/web/src/modules/wiki/wiki.css @@ -61,9 +61,3 @@ .wiki-att__error{color:var(--color-status-error)} /* плашка хода синхронизации в боковой панели */ -.wiki-status{flex-direction:row;align-items:center} -.wiki-status--info .wiki-status__text{color:var(--color-text-primary)} -.wiki-status--error .wiki-status__text{color:var(--color-status-error)} -.wiki-status .wiki-status__dot{position:static;box-shadow:none} -.wiki-status__dot--info{background:var(--color-action-primary)} -.wiki-status__dot--error{background:var(--color-status-error)} diff --git a/web/src/projects/NewProjectDialog.test.tsx b/web/src/projects/NewProjectDialog.test.tsx index 37c2474..479c98c 100644 --- a/web/src/projects/NewProjectDialog.test.tsx +++ b/web/src/projects/NewProjectDialog.test.tsx @@ -12,7 +12,7 @@ import { NewProjectDialog } from "./NewProjectDialog.tsx"; vi.mock("../modules/index.ts", async () => { const { jiraModule: jira } = await import("../modules/jira/index.tsx"); const extra: HubModule = { - name: "extra", pages: [], nav: [], + name: "extra", title: "Extra", pages: [], nav: [], projectFields: [{ name: "space_key", label: "Пространство", hint: "ключ", kind: "text" }], }; return { MODULES: [jira, extra] }; diff --git a/web/src/projects/projects.test.tsx b/web/src/projects/projects.test.tsx index 85cffdf..4cf292b 100644 --- a/web/src/projects/projects.test.tsx +++ b/web/src/projects/projects.test.tsx @@ -30,7 +30,7 @@ function hub(projects: unknown[] = [A, S], over: Record calls.filter((c) => c.method === "GET" && c.url.pathname === path); @@ -97,8 +97,10 @@ describe("проект в приложении", () => { }); it("создание проекта из формы", async () => { + let created = false; const h = hub([A], { - "POST /projects": () => json(201, { project: S, batch_id: 7 }), + "POST /projects": () => { created = true; return json(201, { project: S, batch_id: 7 }); }, + "GET /projects": () => json(200, { projects: created ? [A, S] : [A] }), // блок состояния берёт проект из списка "GET /batches/7": () => json(200, { id: 7, author: "viewer", status: "queued", remaining_seconds: 5, counts: {}, commands: [{ type: "refresh", status: "queued" }] }), }); const router = open("/app/board?project=AWPCA"); @@ -110,9 +112,9 @@ describe("проект в приложении", () => { await userEvent.type(within(dlg).getByLabelText("Название"), "Запасы ДРРК"); await userEvent.click(within(dlg).getByRole("button", { name: "Создать" })); await waitFor(() => expect(router.state.location.search).toBe("?project=STOCKS_DRRK")); - // пакет проекта отслеживается по batch_id (track): верхняя панель показывает ход, список пакетов не запрашивается + // пакет проекта отслеживается по batch_id (trackBatch): панель показывает ход expect(await within(screen.getByRole("complementary", { name: "Панель приложения" })).findByRole("status")).toHaveTextContent(/пакет #7 в очереди/); - expect(h.get("/batches").length).toBe(listed); + expect(h.get("/batches").length).toBeGreaterThanOrEqual(listed); const post = h.calls.find((c) => c.method === "POST" && c.url.pathname === "/projects")!; expect(post.body).toEqual({ label: "STOCKS_DRRK", name: "Запасы ДРРК", author: "viewer" }); expect(screen.queryByRole("dialog", { name: "Новый проект" })).not.toBeInTheDocument(); diff --git a/web/src/shell/Layout.tsx b/web/src/shell/Layout.tsx index 3081458..6b7a806 100644 --- a/web/src/shell/Layout.tsx +++ b/web/src/shell/Layout.tsx @@ -1,10 +1,12 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { useLocation, useNavigate, useRoutes } from "react-router"; import { useProjects } from "../api/queries.ts"; -import { MODULES, useOnProjectCreated, useOverlays } from "../modules/index.ts"; +import { MODULES, useOverlays } from "../modules/index.ts"; import { rememberedProject, rememberProject, resolveProject, withProject } from "../projects/current.ts"; import { NewProjectDialog } from "../projects/NewProjectDialog.tsx"; import { ProjectContext, type ProjectCtx, type ProjectState } from "../projects/ProjectContext.ts"; +import { ActivityProvider } from "./activity/ActivityProvider.tsx"; +import { useTrackBatch } from "./activity/activity.ts"; import { ErrorBoundary } from "./ErrorBoundary.tsx"; import { PAGE_ROUTES } from "./pages.tsx"; import { Sidebar } from "./Sidebar.tsx"; @@ -50,9 +52,9 @@ export function Layout() { const ctx = useMemo(() => ({ state, projects: projects.data ?? [], select, openNew: () => setNewOpen(true) }), [state, projects.data, select]); - const onProjectCreated = useOnProjectCreated(); return ( +
n.to === section)?.padded ? "" : " page--board")}> @@ -60,7 +62,14 @@ export function Layout() {
{overlay?.element} - {newOpen && setNewOpen(false)} onCreated={(p, batchId) => { setNewOpen(false); onProjectCreated(batchId); select(p.label); }} />} + {newOpen && setNewOpen(false)} select={select} />} +
); } + +// окно «Новый проект»: пакет, поставленный хабом при создании, отслеживает трекер ядра (модуль берётся из ответа хаба) +function NewProject({ onClose, select }: { onClose(): void; select(label: string): void }) { + const trackBatch = useTrackBatch(); + return { onClose(); void trackBatch(batchId).catch(() => {}); select(p.label); }} />; +} diff --git a/web/src/shell/Sidebar.test.tsx b/web/src/shell/Sidebar.test.tsx index b96e5ee..c3ab326 100644 --- a/web/src/shell/Sidebar.test.tsx +++ b/web/src/shell/Sidebar.test.tsx @@ -11,8 +11,8 @@ import { routes } from "../routes.tsx"; import { json, mockFetch } from "../test/fetchMock.ts"; const SNAP = JSON.parse(readFileSync(join(import.meta.dirname, "..", "modules", "jira", "board", "__fixtures__", "hub-basic.board.json"), "utf8")); -const A = { label: "AWPCA", name: "АРМ КА", board_id: null, board_type: null, created_at: "", issues_count: 9 }; -const WITH_SYNC = { ...SNAP, last_sync: { started_at: "2026-10-14T11:58:00+00:00", finished_at: "2026-10-14T12:00:00+00:00", issues_count: 9 } }; +const A = { label: "AWPCA", name: "АРМ КА", board_id: null, board_type: null, created_at: "", issues_count: 9, jira_synced_at: "2026-10-14T12:00:00+00:00" }; +const WITH_SYNC = SNAP; interface Call { method: string; url: URL } function hub(over: Record Response> = {}) { @@ -26,7 +26,7 @@ function hub(over: Record Response> = {}) { if (k === "GET /board") return json(200, WITH_SYNC); if (k === "GET /issues") return json(200, { issues: [] }); if (k === "GET /batches") return json(200, { batches: [] }); - if (k === "GET /agent") return json(200, { online: true, last_seen_at: "2026-10-14T09:30:00+00:00" }); + if (k === "GET /agents") return json(200, { agents: [{ module: "jira", online: true, last_seen_at: "2026-10-14T09:30:00+00:00", agent_version: 1, warning: null, update_hint: null, current_command_id: null, release: null }] }); 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: {}, commands: [{ type: "refresh", status: "running" }] }); return json(404, { error: "not_found" }); @@ -83,7 +83,7 @@ describe("боковая панель", () => { await screen.findByTestId("page-issues"); await waitFor(() => expect(h.count("GET", "/issues")).toBeGreaterThan(0)); const issuesBefore = h.count("GET", "/issues"); - const btn = within(bar()).getByRole("button", { name: "Обновить из Jira" }); + const btn = within(bar()).getByRole("button", { name: /^Обновить из Jira/ }); await userEvent.click(btn); await waitFor(() => expect(btn).toBeDisabled()); expect(btn).toHaveAttribute("aria-busy", "true"); @@ -96,7 +96,7 @@ describe("боковая панель", () => { }); it("агент офлайн — «агент офлайн»; на связи — «агент на связи»", async () => { - hub({ "GET /agent": () => json(200, { online: false, last_seen_at: "2026-10-14T09:30:00+00:00" }) }); + hub({ "GET /agents": () => json(200, { agents: [{ module: "jira", online: false, last_seen_at: "2026-10-14T09:30:00+00:00", warning: null, update_hint: null }] }) }); open("/app/board?project=AWPCA"); expect(await within(bar()).findByText("агент офлайн")).toBeInTheDocument(); }); @@ -107,16 +107,24 @@ describe("боковая панель", () => { expect(await within(bar()).findByText("агент на связи")).toBeInTheDocument(); }); - it("«данные на ДД.ММ ЧЧ:ММ» берутся из last_sync снимка", async () => { + it("«данные на ДД.ММ ЧЧ:ММ · все проекты» берутся из jira_synced_at проекта", async () => { hub(); open("/app/issues?project=AWPCA"); - expect(await within(bar()).findByText(/^данные на \d\d\.\d\d \d\d:\d\d$/)).toBeInTheDocument(); + expect(await within(bar()).findByText(/^данные на \d\d\.\d\d \d\d:\d\d · все проекты$/)).toBeInTheDocument(); }); - it("полного обновления не было", async () => { - hub({ "GET /board": () => json(200, { ...SNAP, last_sync: null }) }); + it("обновления ещё не было", async () => { + hub({ "GET /projects": () => json(200, { projects: [{ ...A, jira_synced_at: null }] }) }); open("/app/issues?project=AWPCA"); - expect(await within(bar()).findByText("полного обновления не было")).toBeInTheDocument(); + expect(await within(bar()).findByText("обновления ещё не было")).toBeInTheDocument(); + }); + + it("блок состояния не грузит GET /wiki/status; у проекта без корня вики строки «Вики» нет", async () => { + const h = hub(); + open("/app/board?project=AWPCA"); + await within(bar()).findByRole("button", { name: /^Обновить из Jira/ }); + expect(within(bar()).queryByRole("button", { name: /^Обновить вики/ })).toBeNull(); + expect(h.count("GET", "/wiki/status")).toBe(0); }); it("меню «Ещё» → «Сменить токен» показывает экран входа", async () => { @@ -139,7 +147,7 @@ describe("боковая панель", () => { open("/app/board?project=AWPCA"); await screen.findByTestId("page-board"); await screen.findByText(/задач из/); - expect(screen.getAllByRole("button", { name: "Обновить из Jira" })).toHaveLength(1); + expect(screen.getAllByRole("button", { name: /^Обновить из Jira/ })).toHaveLength(1); }); it("разделы — в навигации «Разделы» боковой панели", async () => { @@ -171,7 +179,7 @@ describe("боковая панель", () => { localStorage.setItem("sdlc-hub-sidebar", "collapsed"); const h = hub(); open("/app/issues?project=AWPCA"); - const btn = await within(bar()).findByRole("button", { name: "Обновить из Jira" }); + const btn = await within(bar()).findByRole("button", { name: /^Обновить из Jira/ }); await userEvent.click(btn); await waitFor(() => expect(btn).toBeDisabled()); expect(h.count("POST", "/refresh")).toBe(1); @@ -202,7 +210,7 @@ describe("боковая панель на узком экране", () => { const top = await screen.findByRole("banner", { name: "Строка приложения" }); expect(within(top).getByRole("button", { name: "Меню" })).toHaveAttribute("aria-expanded", "false"); expect(within(top).getByText("SDLC Hub")).toBeInTheDocument(); - expect(within(top).getByRole("button", { name: "Обновить из Jira" })).toBeInTheDocument(); + expect(await within(top).findByRole("button", { name: /^Обновить из Jira/ })).toBeInTheDocument(); expect(screen.queryByRole("complementary", { name: "Панель приложения" })).toBeNull(); }); @@ -244,7 +252,7 @@ describe("боковая панель на узком экране", () => { hub(); open("/app/board?project=AWPCA"); const top = await screen.findByRole("banner", { name: "Строка приложения" }); - await userEvent.click(within(top).getByRole("button", { name: "Обновить из Jira" })); + await userEvent.click(await within(top).findByRole("button", { name: /^Обновить из Jira/ })); const strip = await screen.findByTestId("topbar-event"); await waitFor(() => expect(strip).toHaveTextContent(/пакет #7 выполняется/)); expect(strip).not.toHaveClass("visually-hidden"); diff --git a/web/src/shell/Sidebar.tsx b/web/src/shell/Sidebar.tsx index 6cfdb7b..3e0440b 100644 --- a/web/src/shell/Sidebar.tsx +++ b/web/src/shell/Sidebar.tsx @@ -3,6 +3,7 @@ import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "reac import { Link } from "react-router"; import { clearToken } from "../auth/token.ts"; import { MODULES } from "../modules/index.ts"; +import { StatusBlock, TopbarNotice } from "./activity/StatusBlock.tsx"; import { withProject } from "../projects/current.ts"; import { ProjectPicker } from "../projects/ProjectPicker.tsx"; import { IconButton, IconClose, IconMenu, IconSidebar, Menu, MenuItem, ThemeSwitch } from "../ui/index.ts"; @@ -42,9 +43,9 @@ function NarrowShell(p: Props) { onClick={() => setOpen(true)}> SDLC Hub - {MODULES.map((m) => m.SidebarStatus && )} + - {MODULES.map((m) => m.TopbarNotice && )} + {open && <>
@@ -91,7 +92,7 @@ function Panel({ section, project, drawer }: Props & { drawer?: { close(): void; ))} - {MODULES.map((m) => m.SidebarStatus && )} +
diff --git a/web/src/shell/activity/ActivityProvider.tsx b/web/src/shell/activity/ActivityProvider.tsx new file mode 100644 index 0000000..e10a180 --- /dev/null +++ b/web/src/shell/activity/ActivityProvider.tsx @@ -0,0 +1,229 @@ +// владелец состояния модулей: один опрос GET /agents и один трекер пакетов на всё приложение, не больше одного пакета +// на модуль. Правила остановки опроса — прежние (hub#20, перенос RefreshProvider модуля jira): одна кнопка и один опрос +// (M-2), остановка опроса (M-4, T10.2). Подхват (M-3) теперь по модулю и без привязки к снимку доски: при монтировании, +// смене проекта, раз в 60 с и после 409 на постановке; пакет любого автора (design web-modules, решение 4) +import { useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query"; +import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { api, ApiError, AuthError, dropTokenOn, HubDownError } from "../../api/client.ts"; +import type { AgentView, Batch } from "../../api/types.ts"; +import { MODULES } from "../../modules/index.ts"; +import type { HubModule } from "../../modules/types.ts"; +import { ActivityContext, AGENTS_KEY, type ActivityValue, type ModuleActivity } from "./activity.ts"; +import { DONE_VISIBLE_MS, MAX_POLL_ERRORS, PICKUP_LIMIT, PICKUP_MS, POLL_MS, type ModuleEvent } from "./events.ts"; + +interface Active { id: number; agentOffline: boolean; own: boolean } // own — пакет действия обновления этого модуля + +const terminal = (b: Batch | undefined) => !!b && (b.status === "done" || b.status === "cancelled"); +const fetchAgents = () => api.get<{ agents: AgentView[] }>("/agents").then((r) => r.agents); +const isError = (e: ModuleEvent | null) => e?.kind === "batch-failed" || e?.kind === "batch-lost" || e?.kind === "hub-down" || e?.kind === "error"; + +// модули с описанием состояния; список — константа: хуки трекеров вызываются в постоянном порядке +const TRACKED = MODULES.filter((m): m is HubModule & Required> => !!m.status); + +interface Env { qc: QueryClient; project: string | null; pollMs: number; agents: AgentView[] | undefined } + +function useTracker(m: (typeof TRACKED)[number], { qc, project, pollMs, agents }: Env) { + const { name, status } = m; + const refresh = status.refresh; + const scoped = status.scope === "project"; + const [event, setEvent] = useState(null); + const [active, setActive] = useState(null); + const [starting, setStarting] = useState(false); + const startingRef = useRef(false); + const failures = useRef(0); + const activeRef = useRef(null); + const picking = useRef(false); + const generation = useRef(0); // растёт при смене проекта: ответы прежнего проекта отбрасываются + const projectRef = useRef(project); + const agentsRef = useRef(agents); + useEffect(() => { projectRef.current = project; agentsRef.current = agents; }); + + const agent = status.agent ? agents?.find((a) => a.module === status.agent) ?? null : null; + const offlineNow = () => !!status.agent && agentsRef.current?.find((a) => a.module === status.agent)?.online === false; + const setActiveNow = useCallback((a: Active | null) => { activeRef.current = a; setActive(a); }, []); + + const report = useCallback((e: unknown) => { + if (e instanceof AuthError) dropTokenOn(e); + else if (e instanceof HubDownError) setEvent({ kind: "hub-down" }); + else setEvent({ kind: "error", message: e instanceof Error ? e.message : String(e) }); + }, []); + + // M-3: подхватить незавершённый пакет модуля (самый старый), если ничего не отслеживается + const pickup = useCallback(() => { + if (!refresh) return; + if (scoped && projectRef.current === null) return; + if (activeRef.current || startingRef.current || picking.current) return; + picking.current = true; + const gen = generation.current; + (async () => { + const r = await api.get<{ batches: Batch[] }>("/batches", { module: name, limit: PICKUP_LIMIT, project: scoped ? projectRef.current : undefined }); + if (gen !== generation.current || activeRef.current || startingRef.current) return; + const open = r.batches.filter((b) => b.status === "queued" || b.status === "running").sort((a, b) => a.id - b.id)[0]; + if (!open) return; + failures.current = 0; + setActiveNow({ id: open.id, agentOffline: offlineNow(), own: false }); + })().catch(() => { /* не критично: подхватим при следующем проходе */ }).finally(() => { if (gen === generation.current) picking.current = false; }); + // eslint-disable-next-line react-hooks/exhaustive-deps -- offlineNow читает ref, имя и охват модуля постоянны + }, [refresh, scoped, name, setActiveNow]); + + // смена проекта: отслеживаемый пакет проектного модуля сбрасывается без события; подхват — по всем модулям + const prevProject = useRef(project); + useEffect(() => { + if (prevProject.current !== project) { + prevProject.current = project; + if (scoped) { + generation.current += 1; + picking.current = false; + failures.current = 0; + setActiveNow(null); + setEvent(null); + } + } + pickup(); + const t = setInterval(pickup, PICKUP_MS); + return () => clearInterval(t); + }, [project, scoped, pickup, setActiveNow]); + + // M-2, M-4: один опрос пакета; остановка на завершении, любой 4xx (T10.2), 401 и после MAX_POLL_ERRORS ошибок подряд + const batch = useQuery({ + queryKey: ["batch", active?.id ?? 0], + enabled: active !== null, + gcTime: 0, + staleTime: 0, + queryFn: async () => { + try { + const b = await api.get(`/batches/${active!.id}`); + failures.current = 0; + return b; + } catch (e) { + if (e instanceof HubDownError) failures.current += 1; + throw e; + } + }, + refetchInterval: (q) => { + const e = q.state.error; + if (terminal(q.state.data)) return false; + if (e instanceof AuthError || e instanceof ApiError) return false; // любая 4xx не лечится повтором + if (failures.current >= MAX_POLL_ERRORS) return false; + return pollMs; + }, + refetchIntervalInBackground: true, + }); + + const finish = useCallback((id: number) => { + failures.current = 0; + setActiveNow(null); + qc.removeQueries({ queryKey: ["batch", id] }); + status.invalidate(qc, projectRef.current); + void qc.invalidateQueries({ queryKey: ["projects"] }); + }, [qc, status, setActiveNow]); + + useEffect(() => { + if (!active) return; + const b = batch.data, e = batch.error; + if (b && b.id === active.id && !batch.isError) { + if (terminal(b)) { + const cmd = b.commands?.[0]; + setEvent(cmd?.status === "failed" ? { kind: "batch-failed", id: b.id, error: cmd.error || "ошибка агента" } + : b.status === "cancelled" || cmd?.status === "cancelled" || cmd?.status === "skipped" ? { kind: "batch-cancelled", id: b.id } + : { kind: "batch-done", id: b.id }); + finish(b.id); + return; + } + if (!active.own && refresh?.isOwn(b)) { setActiveNow({ ...active, own: true }); return; } // подхваченный пакет оказался нашим обновлением + setEvent({ kind: "batch", id: b.id, phase: b.status === "queued" ? "queued" : "running", + remaining: b.remaining_seconds, agentOffline: active.agentOffline, retrying: false }); + return; + } + if (e instanceof ApiError) { + setEvent({ kind: "batch-lost", id: active.id, reason: e.status === 404 ? "пакета нет в хабе" : e.detail ?? e.message }); + finish(active.id); + } else if (e instanceof HubDownError) { + if (failures.current >= MAX_POLL_ERRORS) { + setEvent({ kind: "batch-lost", id: active.id, reason: "хаб не отвечает" }); + finish(active.id); + } else { + setEvent((cur) => (cur?.kind === "batch" ? { ...cur, retrying: true } : cur)); + } + } + }, [batch.data, batch.error, batch.isError, batch.errorUpdatedAt, batch.dataUpdatedAt, active, finish, refresh, setActiveNow]); + + // «пакет выполнен» гаснет сам + useEffect(() => { + if (event?.kind !== "batch-done") return; + const t = setTimeout(() => setEvent((cur) => (cur === event ? null : cur)), DONE_VISIBLE_MS); + return () => clearTimeout(t); + }, [event]); + + const start = useCallback(async () => { + const p = projectRef.current; + if (!refresh || (scoped && p === null)) return; + if (startingRef.current || activeRef.current?.own) return; + startingRef.current = true; + setStarting(true); + let conflict = false; + try { + const list = await qc.fetchQuery({ queryKey: AGENTS_KEY, queryFn: fetchAgents, staleTime: 0 }); + const offline = !!status.agent && list.find((a) => a.module === status.agent)?.online === false; + const gen = generation.current; + let b: { id: number; estimate_seconds?: number }; + try { + b = await refresh.start(p ?? ""); + } catch (e) { + if (e instanceof ApiError && e.status === 409) { conflict = true; return; } // уже идёт: подхватим без события ошибки + throw e; + } + if (gen !== generation.current) return; // проект сменился во время постановки: пакет не этого проекта + failures.current = 0; + setActiveNow({ id: b.id, agentOffline: offline, own: true }); + setEvent({ kind: "batch", id: b.id, phase: "queued", remaining: b.estimate_seconds ?? 0, agentOffline: offline, retrying: false }); + } catch (e) { + report(e); + } finally { + startingRef.current = false; + setStarting(false); + if (conflict) pickup(); + } + }, [refresh, scoped, status, qc, report, pickup, setActiveNow]); + + const track = useCallback((id: number) => { + if (activeRef.current?.own) return; + failures.current = 0; + setActiveNow({ id, agentOffline: false, own: true }); + setEvent({ kind: "batch", id, phase: "queued", remaining: 0, agentOffline: false, retrying: false }); + }, [setActiveNow]); + + const running = active && batch.data?.id === active.id ? batch.data : null; + const busy = starting || !!active?.own; + const view = useMemo(() => ({ agent, event, busy, running, start, track }), + [agent, event, busy, running, start, track]); + return { view, tracking: starting || active !== null }; +} + +// хук-трекер для модуля: имя «use…» позволяет вызывать его в постоянном порядке по константному списку +const TRACKER_HOOKS = TRACKED.map((m) => function useModuleTracker(env: Env) { return useTracker(m, env); }); + +export function ActivityProvider({ children, project, pollMs = POLL_MS }: { children: ReactNode; project: string | null; pollMs?: number }) { + const qc = useQueryClient(); + const agentsQ = useQuery({ queryKey: AGENTS_KEY, queryFn: fetchAgents, refetchInterval: PICKUP_MS, refetchIntervalInBackground: true }); + const agents = agentsQ.data; + const env: Env = { qc, project, pollMs, agents }; + const trackers = TRACKER_HOOKS.map((use) => use(env)); // постоянный порядок: список — константа + + const modules = useMemo(() => Object.fromEntries(TRACKED.map((m, i) => [m.name, trackers[i].view])), + // eslint-disable-next-line react-hooks/exhaustive-deps -- представления модулей мемоизированы в трекерах + trackers.map((t) => t.view)); + const summary: ActivityValue["summary"] = + TRACKED.some((m) => isError(modules[m.name].event)) ? "error" + : TRACKED.some((m) => !!m.status.agent && modules[m.name].agent?.online === false) ? "offline" + : trackers.some((t) => t.tracking) ? "busy" : "online"; + + const trackBatch = useCallback(async (id: number) => { + const b = await api.get(`/batches/${id}`); + const m = TRACKED.find((x) => x.name === b.module) ?? TRACKED.find((x) => x.status.refresh?.isOwn(b)); + if (m) modules[m.name].track(id); + }, [modules]); + + const value = useMemo(() => ({ agents: agents ?? [], modules, summary, trackBatch }), [agents, modules, summary, trackBatch]); + return {children}; +} diff --git a/web/src/shell/activity/StatusBlock.tsx b/web/src/shell/activity/StatusBlock.tsx new file mode 100644 index 0000000..b7e733c --- /dev/null +++ b/web/src/shell/activity/StatusBlock.tsx @@ -0,0 +1,74 @@ +// ВРЕМЕННЫЙ блок состояния модулей (Task 3): по строке на модуль с данными для проекта. Task 4 заменяет его +// полноценным блоком панели («Состояние и обновление модулей»); здесь — данные контракта и прежние классы +import { useProjects } from "../../api/queries.ts"; +import type { Project } from "../../api/types.ts"; +import { MODULES } from "../../modules/index.ts"; +import type { HubModule } from "../../modules/types.ts"; +import { fmtStamp } from "../../ui/format.ts"; +import { IconButton, IconRefresh } from "../../ui/index.ts"; +import { useModuleActivity } from "./activity.ts"; +import { eventText } from "./events.ts"; + +const WITH_STATUS = MODULES.filter((m): m is HubModule & Required> => !!m.status); + +function ModuleRow({ m, project, compact }: { m: (typeof WITH_STATUS)[number]; project: Project; compact: boolean }) { + const act = useModuleActivity(m.name); + const { status } = m; + const fresh = status.freshness(project); + const label = status.refresh?.label; + const scope = status.scope === "all" ? "все проекты" : "проект"; + const action = label ? `${label} — ${status.scope === "all" ? scope : `${scope} ${project.label}`}` : null; + const a = act.agent; + const agentText = a ? (a.warning || a.update_hint ? "обновите агента" : a.online ? "агент на связи" : "агент офлайн") : null; + const agentHint = a ? [a.warning, a.update_hint, a.last_seen_at ? `последний выход на связь: ${fmtStamp(a.last_seen_at)}` : "выхода на связь ещё не было"].filter(Boolean).join(" · ") : undefined; + const freshText = fresh.error ? `синхронизация не удалась: ${fresh.error}` + : fresh.at ? `данные на ${fmtStamp(fresh.at)} · ${scope}` : "обновления ещё не было"; + const ev = act.event ? eventText(act.event, label ?? "") : null; + const online = !!a && a.online && !a.warning && !a.update_hint; + const eventEl = {ev?.text}; + const button = action && ( +
diff --git a/web/src/modules/jira/board/components/Card.tsx b/web/src/modules/jira/board/components/Card.tsx index 1290534..a8e5dd5 100644 --- a/web/src/modules/jira/board/components/Card.tsx +++ b/web/src/modules/jira/board/components/Card.tsx @@ -36,7 +36,7 @@ export const Card = memo(function Card({ node: n, box, rel, multiSprint, sprintS onPointerDown={onPointerDown ? (e) => onPointerDown(e, n.key) : undefined}>
{n.key} - {!n.stub && } {showTag && (n.sprint ? {n.sprint.replace(/^AWPCA_/, "")} diff --git a/web/src/modules/jira/board/useBoardSource.ts b/web/src/modules/jira/board/useBoardSource.ts index bcac7fc..e8ed36e 100644 --- a/web/src/modules/jira/board/useBoardSource.ts +++ b/web/src/modules/jira/board/useBoardSource.ts @@ -1,5 +1,5 @@ // источник данных доски: снимок хаба (GET /board) или файл (CSV/JSON). Обновление из Jira живёт в оболочке -// (refresh/RefreshProvider.tsx); здесь — явный источник (M-1) и одно событие состояния доски (M-5) +// (трекер пакетов ядра, shell/activity/); здесь — явный источник (M-1) и одно событие состояния доски (M-5) import { useQueryClient } from "@tanstack/react-query"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { AuthError, dropTokenOn, HubDownError } from "../../../api/client.ts"; diff --git a/web/src/modules/jira/feed/FeedPage.test.tsx b/web/src/modules/jira/feed/FeedPage.test.tsx index 3ac3009..a6e5810 100644 --- a/web/src/modules/jira/feed/FeedPage.test.tsx +++ b/web/src/modules/jira/feed/FeedPage.test.tsx @@ -59,7 +59,7 @@ describe("лента изменений", () => { vi.useFakeTimers({ toFake: ["Date"] }); vi.setSystemTime(new Date(2026, 9, 22, 10, 0)); const calls = hub(); - open("/app/feed?project=AWPCA"); + open("/app/jira/feed?project=AWPCA"); await page(); await waitFor(() => expect(calls.length).toBe(1)); expect(new Date(calls[0].searchParams.get("until")!).getTime()).toBe(new Date(2026, 9, 22, 10, 0).getTime()); @@ -75,7 +75,7 @@ describe("лента изменений", () => { ev({}), ev({ key: "AURORA-9001", summary: "Схема", status: "В работе", kind: "comment", field: "comment", from: null, to: "Готово к проверке" }), ev({ at: minutesAgo(40), kind: "description", field: "description" }), ] })); - open("/app/feed?project=AWPCA"); + open("/app/jira/feed?project=AWPCA"); await page(); const groups = await screen.findAllByTestId("feed-group"); expect(groups.map((g) => within(g).getByRole("link").textContent)).toEqual(["AURORA-9002", "AURORA-9001"]); @@ -95,12 +95,12 @@ describe("лента изменений", () => { it("«Что закрылось за два часа»: период и виды в адресе и в запросе", async () => { const calls = hub(); - const router = open("/app/feed?project=AWPCA"); + const router = open("/app/jira/feed?project=AWPCA"); await page(); await userEvent.click(screen.getByRole("radio", { name: "2 часа" })); for (const name of ["Описание", "Сроки", "Комментарии"]) await userEvent.click(screen.getByRole("checkbox", { name })); await waitFor(() => expect(router.state.location.search).toBe("?project=AWPCA&period=2h&kinds=status")); - expect(router.state.location.pathname).toBe("/app/feed"); + expect(router.state.location.pathname).toBe("/app/jira/feed"); await waitFor(() => expect(calls.at(-1)!.searchParams.get("kinds")).toBe("status")); const since = new Date(calls.at(-1)!.searchParams.get("since")!).getTime(); expect(Math.abs(Date.now() - 2 * 3600_000 - since)).toBeLessThan(60_000); @@ -108,7 +108,7 @@ describe("лента изменений", () => { it("карточка из ленты — окно поверх, закрытие возвращает ленту с тем же периодом", async () => { hub(() => ({ changes: [ev({})] })); - const router = open("/app/feed?project=AWPCA&period=7d"); + const router = open("/app/jira/feed?project=AWPCA&period=7d"); await page(); await userEvent.click(await screen.findByRole("link", { name: "AURORA-9002" })); const d = await screen.findByRole("dialog"); @@ -116,19 +116,19 @@ describe("лента изменений", () => { expect(screen.getByTestId("page-feed")).toBeInTheDocument(); await userEvent.click(within(d).getAllByRole("button", { name: "Закрыть" })[0]); await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); - expect(router.state.location.pathname + router.state.location.search).toBe("/app/feed?project=AWPCA&period=7d"); + expect(router.state.location.pathname + router.state.location.search).toBe("/app/jira/feed?project=AWPCA&period=7d"); }); - it("M-1: /app/feed без project — проект дописывается в адрес", async () => { + it("M-1: /app/jira/feed без project — проект дописывается в адрес", async () => { hub(); - const router = open("/app/feed"); + const router = open("/app/jira/feed"); await page(); await waitFor(() => expect(router.state.location.search).toBe("?project=AWPCA")); }); it("M-3/M-6/M-7: подсказка truncated над группами, автор без имени — «—», группы не регионы", async () => { hub(() => ({ truncated: true, changes: [ev({ author: null })] })); - open("/app/feed?project=AWPCA"); + open("/app/jira/feed?project=AWPCA"); const notice = await screen.findByText("показаны последние 1 событий — выберите период короче"); const group = screen.getByTestId("feed-group"); expect(notice.compareDocumentPosition(group) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); @@ -140,7 +140,7 @@ describe("лента изменений", () => { let mode: "empty" | "truncated" | "error" = "empty"; hub(() => mode === "error" ? json(500, { error: "boom", detail: "сбой" }) : mode === "truncated" ? { truncated: true, changes: [ev({})] } : { changes: [] }); - open("/app/feed?project=AWPCA"); + open("/app/jira/feed?project=AWPCA"); expect(await screen.findByText("за период изменений нет")).toBeInTheDocument(); mode = "truncated"; await userEvent.click(screen.getByRole("radio", { name: "Вчера" })); @@ -155,7 +155,7 @@ describe("лента изменений", () => { it("все флажки сняты — подсказка, запроса нет", async () => { const calls = hub(); - open("/app/feed?project=AWPCA&kinds=status"); + open("/app/jira/feed?project=AWPCA&kinds=status"); await page(); await waitFor(() => expect(calls.length).toBe(1)); await userEvent.click(screen.getByRole("checkbox", { name: "Статусы" })); @@ -165,7 +165,7 @@ describe("лента изменений", () => { it("мусор в адресе: период по умолчанию, неизвестные виды отброшены", async () => { const calls = hub(); - open("/app/feed?project=AWPCA&period=foo&kinds=status,foo"); + open("/app/jira/feed?project=AWPCA&period=foo&kinds=status,foo"); await page(); await waitFor(() => expect(calls.length).toBeGreaterThan(0)); expect(screen.getByRole("radio", { name: "Сегодня" })).toHaveAttribute("aria-checked", "true"); @@ -176,12 +176,12 @@ describe("лента изменений", () => { it("смена проекта: запрос по новому проекту, период и виды сохраняются", async () => { const calls = hub(); - const router = open("/app/feed?project=AWPCA&period=2h&kinds=status"); + const router = open("/app/jira/feed?project=AWPCA&period=2h&kinds=status"); await page(); await userEvent.selectOptions(screen.getByRole("combobox", { name: "Проект" }), "STOCKS"); await waitFor(() => expect(calls.at(-1)!.searchParams.get("project")).toBe("STOCKS")); expect(calls.at(-1)!.searchParams.get("kinds")).toBe("status"); - expect(router.state.location.pathname).toBe("/app/feed"); + expect(router.state.location.pathname).toBe("/app/jira/feed"); expect(router.state.location.search).toBe("?project=STOCKS&period=2h&kinds=status"); }); }); diff --git a/web/src/modules/jira/index.tsx b/web/src/modules/jira/index.tsx index b8dd4b3..94322b3 100644 --- a/web/src/modules/jira/index.tsx +++ b/web/src/modules/jira/index.tsx @@ -1,4 +1,4 @@ -import { Navigate } from "react-router"; +import { Navigate, useLocation, useParams } from "react-router"; import { ProjectGate } from "../../projects/ProjectGate.tsx"; import { IconBoard, IconHistory, IconList } from "../../ui/index.ts"; import type { HubModule } from "../types.ts"; @@ -8,19 +8,31 @@ import { useIssueOverlay } from "./issues/overlay.tsx"; import { IssuesPage } from "./issues/IssuesPage.tsx"; import { jiraStatus } from "./status.ts"; +// прежний адрес → новый; keyParam — параметр пути (ключ задачи), который дописывается к новому адресу +function LegacyRedirect({ to, keyParam }: { to: string; keyParam?: string }) { + const { search } = useLocation(); + const params = useParams(); + const key = keyParam ? params[keyParam] : undefined; + return ; +} + export const jiraModule: HubModule = { name: "jira", title: "Jira", pages: [ - { path: "/", element: }, - { path: "/board", element: {(p) => } }, - { path: "/issues", element: {(p) => } }, - { path: "/feed", element: {(p) => } }, + { path: "/jira/board", element: {(p) => } }, + { path: "/jira/issues", element: {(p) => } }, + { path: "/jira/feed", element: {(p) => } }, + // прежние адреса (закладки): перенаправление с сохранением строки запроса, без новой записи истории + { path: "/board", element: }, + { path: "/issues", element: }, + { path: "/issues/:key", element: }, + { path: "/feed", element: }, ], nav: [ - { to: "/board", label: "Доска связей", icon: }, - { to: "/issues", label: "Задачи", icon: , padded: true }, - { to: "/feed", label: "Лента", icon: , padded: true, keepSearch: true }, + { to: "/jira/board", label: "Доска связей", icon: }, + { to: "/jira/issues", label: "Задачи", icon: , padded: true }, + { to: "/jira/feed", label: "Лента", icon: , padded: true, keepSearch: true }, ], status: jiraStatus, useOverlay: useIssueOverlay, diff --git a/web/src/modules/jira/issues/IssueCard.test.tsx b/web/src/modules/jira/issues/IssueCard.test.tsx index f1ba65f..4dc6284 100644 --- a/web/src/modules/jira/issues/IssueCard.test.tsx +++ b/web/src/modules/jira/issues/IssueCard.test.tsx @@ -24,7 +24,7 @@ function CardAt() { function open(path: string, client = makeQueryClient()) { const router = createMemoryRouter([ - { path: "/issues/:key", element: }, + { path: "/jira/issues/:key", element: }, { path: "/board", element:

доска

}, ], { initialEntries: [path] }); render(); @@ -43,7 +43,7 @@ describe("карточка задачи", () => { it("шапка: ключ, статус Jira, тема, исполнитель · срок · спринт · эпик по имени; ссылка в Jira", async () => { mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { planned_end: "2020-01-10" }))); - open("/issues/AURORA-9001"); + open("/jira/issues/AURORA-9001"); expect(await screen.findByRole("heading", { level: 2, name: "Тема AURORA-9001" })).toBeInTheDocument(); const head = screen.getByTestId("issue-head"); expect(within(head).getByText("AURORA-9001")).toBeInTheDocument(); @@ -62,11 +62,11 @@ describe("карточка задачи", () => { links_in: [{ source_key: "AURORA-9003", link_type: "ASUPR_Блокер_Завершения", summary: "Макет экрана", status: "Готово" }, { source_key: "AURORA-9008", link_type: "Duplicate", summary: "Актуализация ТЗ", status: "В работе" }], }))); - open("/issues/AURORA-9001"); + open("/jira/issues/AURORA-9001"); const depends = await screen.findByRole("group", { name: "Зависит от" }); expect(within(depends).getByRole("listitem")).toHaveTextContent("завершение AURORA-9003 Макет экрана Готово"); const blocks = screen.getByRole("group", { name: "Блокирует" }); - expect(within(blocks).getByRole("link", { name: "AURORA-9002" })).toHaveAttribute("href", "/issues/AURORA-9002"); + expect(within(blocks).getByRole("link", { name: "AURORA-9002" })).toHaveAttribute("href", "/jira/issues/AURORA-9002"); expect(within(blocks).getByRole("listitem")).toHaveTextContent("начало AURORA-9002 API свода Новая"); expect(within(screen.getByRole("group", { name: "Связана с" })).getByRole("listitem")).toHaveTextContent("lower-1 нет в зеркале"); expect(within(screen.getByRole("group", { name: "Прочие" })).getByRole("listitem")).toHaveTextContent("← Duplicate AURORA-9008"); @@ -75,7 +75,7 @@ describe("карточка задачи", () => { it("без связей — «связей нет»", async () => { mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { links_out: [], links_in: [] }))); - open("/issues/AURORA-9001"); + open("/jira/issues/AURORA-9001"); expect(await screen.findByText("связей нет")).toBeInTheDocument(); }); @@ -83,7 +83,7 @@ describe("карточка задачи", () => { const restore = overflow(500, 180); const long = Array.from({ length: 20 }, (_, n) => `абзац ${n + 1}`).join("\n\n"); mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { description: long }))); - open("/issues/AURORA-9001"); + open("/jira/issues/AURORA-9001"); const body = (await screen.findByText("абзац 1")).closest(".description")!; expect(body.tagName).toBe("DIV"); expect(body).toHaveClass("clamped"); @@ -104,7 +104,7 @@ describe("карточка задачи", () => { let restore = overflow(0, 0); const long = Array.from({ length: 20 }, (_, n) => `абзац ${n + 1}`).join("\n\n"); mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { description: long }))); - open("/issues/AURORA-9001"); + open("/jira/issues/AURORA-9001"); await screen.findByText("абзац 1"); expect(screen.queryByRole("button", { name: "развернуть" })).toBeNull(); restore(); @@ -118,7 +118,7 @@ describe("карточка задачи", () => { it("исходник длиннее 8 строк свёрнут построчно", async () => { const long = Array.from({ length: 10 }, (_, n) => `строка ${n + 1}`).join("\n"); mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { description: long }))); - open("/issues/AURORA-9001"); + open("/jira/issues/AURORA-9001"); await userEvent.click(await screen.findByRole("button", { name: "исходник" })); const pre = screen.getByText(long, { normalizer: (s) => s }); expect(pre).toHaveClass("description", "clamped"); @@ -129,7 +129,7 @@ describe("карточка задачи", () => { const restore = overflow(120, 180); const d = " * один\n\n * два\n\n * три\n\n * четыре\n\n * пять"; mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { description: d }))); - open("/issues/AURORA-9001"); + open("/jira/issues/AURORA-9001"); expect(await screen.findByText("пять")).toBeInTheDocument(); expect(screen.queryByRole("button", { name: "развернуть" })).toBeNull(); restore(); @@ -139,7 +139,7 @@ describe("карточка задачи", () => { const d = "h3. Контекст\n# Загрузить ОСВ\n# Открыть документ"; const comments = [{ comment_id: "1", author: "Иванов Иван", created: null, updated: null, body: "*важно*" }]; mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { description: d, comments }))); - open("/issues/AURORA-9001"); + open("/jira/issues/AURORA-9001"); expect(await screen.findByRole("heading", { level: 3, name: "Контекст" })).toBeInTheDocument(); const section = screen.getByRole("heading", { name: "Описание" }).closest("section")!; await userEvent.click(within(section).getByRole("button", { name: "исходник" })); @@ -155,13 +155,13 @@ describe("карточка задачи", () => { { comment_id: "2", author: "Петров", created: null, updated: null, body: "*b*" }, { comment_id: "1", author: "Иванов", created: null, updated: null, body: "*a*" }]; mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue(url.pathname.split("/").pop()!, { comments }))); - const router = open("/issues/AURORA-9001"); + const router = open("/jira/issues/AURORA-9001"); await screen.findByRole("heading", { name: "Комментарии (2)" }); const items = () => within(screen.getByTestId("comments")).getAllByRole("listitem"); await userEvent.click(within(items()[1]).getByRole("button", { name: "исходник" })); expect(items()[1].querySelector("pre.body")).toHaveTextContent("*a*"); expect(within(items()[0]).getByText("b").tagName).toBe("STRONG"); - await act(() => router.navigate("/issues/AURORA-9002")); + await act(() => router.navigate("/jira/issues/AURORA-9002")); await screen.findByRole("heading", { level: 2, name: "Тема AURORA-9002" }); await act(() => router.navigate(-1)); await screen.findByRole("heading", { level: 2, name: "Тема AURORA-9001" }); @@ -172,24 +172,24 @@ describe("карточка задачи", () => { it("ключ задачи проекта в комментарии — ссылка на карточку", async () => { const comments = [{ comment_id: "1", author: "Иванов", created: null, updated: null, body: "П. 1 ведётся в AURORA-9002, не UTF-8" }]; mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue(url.pathname.split("/").pop()!, { comments }))); - const router = open("/issues/AURORA-9001"); + const router = open("/jira/issues/AURORA-9001"); const comment = within(await screen.findByTestId("comments")); expect(comment.queryByRole("link", { name: "UTF-8" })).toBeNull(); await userEvent.click(comment.getByRole("link", { name: "AURORA-9002" })); - expect(router.state.location.pathname).toBe("/issues/AURORA-9002"); + expect(router.state.location.pathname).toBe("/jira/issues/AURORA-9002"); }); it("короткое описание — без кнопки; нет описания — нет раздела", async () => { let d: string | null = "строка 1\nстрока 2"; mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { description: d }))); - open("/issues/AURORA-9001"); + open("/jira/issues/AURORA-9001"); const body = (await screen.findByText(/строка 1/)).closest(".description")!; expect(body).toHaveTextContent("строка 1строка 2"); expect(body.querySelector("br")).not.toBeNull(); expect(screen.queryByRole("button", { name: "развернуть" })).toBeNull(); d = null; cleanup(); - open("/issues/AURORA-9001"); + open("/jira/issues/AURORA-9001"); await screen.findByRole("heading", { level: 2 }); expect(screen.queryByRole("heading", { name: "Описание" })).toBeNull(); }); @@ -199,7 +199,7 @@ describe("карточка задачи", () => { { comment_id: "2", author: "Петров Пётр", created: "2026-10-15T12:00:00.000+0300", updated: null, body: "новый\nвторая строка" }, { comment_id: "1", author: "Иванов Иван", created: "2026-10-14T12:00:00.000+0300", updated: null, body: "старый" }]; mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { comments }))); - open("/issues/AURORA-9001"); + open("/jira/issues/AURORA-9001"); expect(await screen.findByRole("heading", { name: "Комментарии (2)" })).toBeInTheDocument(); const items = within(screen.getByTestId("comments")).getAllByRole("listitem"); expect(items[0]).toHaveTextContent("Петров Пётр"); @@ -209,7 +209,7 @@ describe("карточка задачи", () => { expect(screen.queryByText("старый")).toBeNull(); expect(localStorage.getItem("sdlc-hub-comments-hidden")).toBe("1"); cleanup(); - open("/issues/AURORA-9001"); + open("/jira/issues/AURORA-9001"); expect(await screen.findByRole("button", { name: "Показать" })).toBeInTheDocument(); expect(screen.queryByText("старый")).toBeNull(); }); @@ -217,19 +217,19 @@ describe("карточка задачи", () => { it("комментарий без автора и текста не ломает карточку; без комментариев — «комментариев нет»", async () => { let comments = [{ comment_id: "1", author: null, created: null, updated: null, body: "" }]; mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { comments }))); - open("/issues/AURORA-9001"); + open("/jira/issues/AURORA-9001"); expect(await screen.findByRole("heading", { name: "Комментарии (1)" })).toBeInTheDocument(); expect(within(screen.getByTestId("comments")).getByRole("listitem")).toHaveTextContent("—"); comments = []; cleanup(); - open("/issues/AURORA-9001"); + open("/jira/issues/AURORA-9001"); expect(await screen.findByText("комментариев нет")).toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Скрыть" })).toBeNull(); }); it("прочие поля и команды моста свёрнуты", async () => { mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001"))); - open("/issues/AURORA-9001"); + open("/jira/issues/AURORA-9001"); const other = await screen.findByRole("region", { name: "Прочие поля" }); expect(other).toHaveTextContent("Приоритет"); expect(other).toHaveTextContent("Высокий"); @@ -240,13 +240,13 @@ describe("карточка задачи", () => { it("заголовок карточки — h2 с id для aria-labelledby окна", async () => { mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001"))); - open("/issues/AURORA-9001"); + open("/jira/issues/AURORA-9001"); expect(await screen.findByRole("heading", { level: 2, name: /AURORA-9001/ })).toHaveAttribute("id", "issue-modal-title"); }); it("задачи нет в зеркале — сообщение и «Закрыть»", async () => { mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(404, { error: "not_found", detail: "issue" })); - const router = open("/issues/AURORA-1"); + const router = open("/jira/issues/AURORA-1"); expect(await screen.findByText("Задачи AURORA-1 нет в зеркале хаба.")).toBeInTheDocument(); await userEvent.click(screen.getByRole("button", { name: "Закрыть" })); expect(router.state.location.pathname).toBe("/board"); @@ -259,7 +259,7 @@ describe("карточка задачи", () => { if (m === "PUT") { put = body; return json(200, note({ rest_text: "тесты", done_text: null, updated_by: "web" })); } return json(200, issue("AURORA-9001")); }); - open("/issues/AURORA-9001"); + open("/jira/issues/AURORA-9001"); await userEvent.click(await screen.findByRole("button", { name: "Править" })); const rest = await screen.findByLabelText("Осталось"); await userEvent.clear(rest); await userEvent.type(rest, "тесты"); @@ -277,7 +277,7 @@ describe("карточка задачи", () => { if (m === "PUT") return json(422, { error: "bad_note", detail: "неизвестные поля заметки: x" }); return json(200, issue("AURORA-9001")); }); - open("/issues/AURORA-9001"); + open("/jira/issues/AURORA-9001"); await userEvent.click(await screen.findByRole("button", { name: "Править" })); const owner = await screen.findByLabelText("Заметка владельца"); await userEvent.type(owner, "проверить"); @@ -288,13 +288,13 @@ describe("карточка задачи", () => { it("уход с несохранёнными правками требует подтверждения", async () => { mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001"))); - const router = open("/issues/AURORA-9001"); + const router = open("/jira/issues/AURORA-9001"); await userEvent.click(await screen.findByRole("button", { name: "Править" })); await userEvent.type(await screen.findByLabelText("Заметка владельца"), "черновик"); await act(async () => { await router.navigate("/board"); }); expect(await screen.findByText("Есть несохранённые правки заметки. Уйти без сохранения?")).toBeInTheDocument(); await userEvent.click(screen.getByRole("button", { name: "Остаться" })); - expect(router.state.location.pathname).toBe("/issues/AURORA-9001"); + expect(router.state.location.pathname).toBe("/jira/issues/AURORA-9001"); expect(screen.getByLabelText("Заметка владельца")).toHaveValue("черновик"); }); @@ -306,8 +306,8 @@ describe("карточка задачи", () => { if (url.pathname === "/issues/AURORA-9001") return slow.promise; return json(200, issue("AURORA-9002")); }); - const router = open("/issues/AURORA-9001", client); - await act(async () => { await router.navigate("/issues/AURORA-9002"); }); + const router = open("/jira/issues/AURORA-9001", client); + await act(async () => { await router.navigate("/jira/issues/AURORA-9002"); }); expect(await screen.findByRole("heading", { name: /AURORA-9002/ })).toBeInTheDocument(); slow.resolve(json(200, issue("AURORA-9001"))); await waitFor(() => expect(client.getQueryData(qk.issue("AURORA-9001"))).toBeDefined()); @@ -326,11 +326,11 @@ describe("карточка задачи", () => { // 9002 уже в кэше: форма не размонтируется при переходе, её состояние переживает навигацию const client = makeQueryClient(); client.setQueryData(qk.issue("AURORA-9002"), issue("AURORA-9002")); - const router = open("/issues/AURORA-9001", client); + const router = open("/jira/issues/AURORA-9001", client); await userEvent.click(await screen.findByRole("button", { name: "Править" })); await userEvent.type(await screen.findByLabelText("Заметка владельца"), "черновик"); await userEvent.click(screen.getByRole("button", { name: "Сохранить" })); - await act(async () => { await router.navigate("/issues/AURORA-9002"); }); + await act(async () => { await router.navigate("/jira/issues/AURORA-9002"); }); await userEvent.click(await screen.findByRole("button", { name: "Уйти" })); expect(await screen.findByRole("heading", { name: /AURORA-9002/ })).toBeInTheDocument(); slowPut.resolve(json(200, note({ owner_note: "черновик", updated_by: "web" }))); @@ -347,7 +347,7 @@ describe("карточка задачи", () => { if (m === "PUT") return slowPut.promise; return json(200, issue("AURORA-9001")); }); - open("/issues/AURORA-9001"); + open("/jira/issues/AURORA-9001"); await userEvent.click(await screen.findByRole("button", { name: "Править" })); await userEvent.type(await screen.findByLabelText("Заметка владельца"), "раз"); await userEvent.click(screen.getByRole("button", { name: "Сохранить" })); @@ -368,7 +368,7 @@ describe("карточка задачи", () => { if (down) throw new TypeError("Failed to fetch"); return json(200, issue("AURORA-9001")); }); - open("/issues/AURORA-9001", client); + open("/jira/issues/AURORA-9001", client); expect(await screen.findByRole("heading", { name: /AURORA-9001/ })).toBeInTheDocument(); await act(async () => { await client.invalidateQueries({ queryKey: qk.issue("AURORA-9001") }); }); // повторная загрузка при данных в кэше expect(await screen.findByRole("alert")).toHaveTextContent("хаб не отвечает"); @@ -380,14 +380,14 @@ describe("карточка задачи", () => { it("O-11: задача без команд — текст вместо пустой таблицы", async () => { mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { commands: [] }))); - open("/issues/AURORA-9001"); + open("/jira/issues/AURORA-9001"); expect(await screen.findByText("команд по задаче не было")).toBeInTheDocument(); expect(screen.queryByRole("columnheader", { name: "Команда" })).toBeNull(); }); it("закрытая задача со сроком в прошлом — без пометки «просрочено»", async () => { mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { planned_end: "2020-01-10", status: "Готово" }))); - open("/issues/AURORA-9001"); + open("/jira/issues/AURORA-9001"); const facts = await screen.findByTestId("issue-facts"); expect(facts).toHaveTextContent("срок 10.01.2020"); expect(within(facts).queryByText("просрочено")).toBeNull(); @@ -395,7 +395,7 @@ describe("карточка задачи", () => { it("статуса по факту нет ни в чтении заметок, ни в форме", async () => { mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001"))); - open("/issues/AURORA-9001"); + open("/jira/issues/AURORA-9001"); const view = await screen.findByRole("region", { name: "Служебные заметки" }); expect(view).not.toHaveTextContent("Статус по факту"); expect(view).toHaveTextContent("Вид работы"); @@ -407,7 +407,7 @@ describe("карточка задачи", () => { it("заметки в режиме чтения: пустые — «—», время и автор; «Отмена» без правок — сразу", async () => { mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001"))); - open("/issues/AURORA-9001"); + open("/jira/issues/AURORA-9001"); const view = await screen.findByRole("region", { name: "Служебные заметки" }); expect(view).toHaveTextContent("Сделано"); expect(view).toHaveTextContent("схема"); @@ -420,7 +420,7 @@ describe("карточка задачи", () => { it("«Отмена» с правками спрашивает; «Отменить правки» возвращает чтение без изменений", async () => { mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001"))); - open("/issues/AURORA-9001"); + open("/jira/issues/AURORA-9001"); await userEvent.click(await screen.findByRole("button", { name: "Править" })); await userEvent.type(screen.getByLabelText("Заметка владельца"), "черновик"); await userEvent.click(screen.getByRole("button", { name: "Отмена" })); @@ -435,7 +435,7 @@ describe("карточка задачи", () => { it("заметки ещё нет — режим чтения с «—» и «Править»", async () => { mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { note: null }))); - open("/issues/AURORA-9001"); + open("/jira/issues/AURORA-9001"); const view = await screen.findByRole("region", { name: "Служебные заметки" }); expect(within(view).getAllByText("—")).toHaveLength(4); // вид работы, сделано, осталось, заметка владельца expect(within(view).getByRole("button", { name: "Править" })).toBeInTheDocument(); @@ -445,7 +445,7 @@ describe("карточка задачи", () => { const err = vi.spyOn(console, "error").mockImplementation(() => {}); const dup = { target_key: "AURORA-9002", link_type: "ASUPR_Блокер_Начала", summary: "API", status: "Новая" }; mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { links_out: [dup, dup] }))); - open("/issues/AURORA-9001"); + open("/jira/issues/AURORA-9001"); await screen.findAllByRole("link", { name: "AURORA-9002" }); expect(err.mock.calls.flat().join(" ")).not.toContain("same key"); err.mockRestore(); @@ -461,7 +461,7 @@ describe("история в карточке", () => { it("12 событий: видны 10 последних и «Показать все (12)», после нажатия — все", async () => { mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { changes: hist(12) }))); - open("/issues/AURORA-9001"); + open("/jira/issues/AURORA-9001"); const sec = await screen.findByTestId("history"); expect(within(sec).getByRole("heading", { name: "История (12)" })).toBeInTheDocument(); expect(within(sec).getAllByRole("listitem")).toHaveLength(10); @@ -473,12 +473,12 @@ describe("история в карточке", () => { it("пустая история — «изменений нет»; без поля changes (старый хаб) — блока нет", async () => { let body = issue("AURORA-9001", { changes: [] }); mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, body)); - open("/issues/AURORA-9001"); + open("/jira/issues/AURORA-9001"); const sec = await screen.findByTestId("history"); expect(within(sec).getByText("изменений нет")).toBeInTheDocument(); cleanup(); body = issue("AURORA-9002"); - open("/issues/AURORA-9002"); + open("/jira/issues/AURORA-9002"); await screen.findByRole("heading", { name: "Тема AURORA-9002" }); expect(screen.queryByTestId("history")).toBeNull(); }); @@ -498,7 +498,7 @@ describe("вложения в карточке", () => { it("раздел «Вложения (2)» после «Описание», новые сверху; размер, автор · дата", async () => { hub({}); - open("/issues/AURORA-1650"); + open("/jira/issues/AURORA-1650"); const sec = within(await screen.findByTestId("attachments")); expect(sec.getByRole("heading", { level: 3, name: "Вложения (2)" })).toBeInTheDocument(); const heads = screen.getAllByRole("heading", { level: 3 }).map((h) => h.textContent); @@ -514,20 +514,20 @@ describe("вложения в карточке", () => { it("без поля attachments (старый хаб) раздела нет", async () => { hub({ attachments: undefined, comments: [] }); - open("/issues/AURORA-1650"); + open("/jira/issues/AURORA-1650"); await screen.findByText("Описание"); expect(screen.queryByTestId("attachments")).toBeNull(); }); it("состояние ready — отметка «у хаба»", async () => { hub({ attachments: [att("31337", "скрин 1.png", { state: "ready" })], comments: [] }); - open("/issues/AURORA-1650"); + open("/jira/issues/AURORA-1650"); expect(within(await screen.findByTestId("attachments")).getByText("у хаба")).toBeInTheDocument(); }); it("25 МБ — ссылка «Открыть в Jira» с подсказкой вместо кнопки", async () => { hub({ attachments: [att("77", "запись.mp4", { size: 25 * 1024 ** 2, mime_type: "video/mp4" })], comments: [] }); - open("/issues/AURORA-1650"); + open("/jira/issues/AURORA-1650"); const sec = within(await screen.findByTestId("attachments")); const a = sec.getByRole("link", { name: "Открыть в Jira" }); expect(a).toHaveAttribute("href", "https://sdlc.go.rshbank.ru/jira/secure/attachment/77/%D0%B7%D0%B0%D0%BF%D0%B8%D1%81%D1%8C.mp4"); @@ -538,7 +538,7 @@ describe("вложения в карточке", () => { it("размер неизвестен — подпись и обычная кнопка", async () => { hub({ attachments: [att("5", "x.bin", { size: null })], comments: [] }); - open("/issues/AURORA-1650"); + open("/jira/issues/AURORA-1650"); const sec = within(await screen.findByTestId("attachments")); expect(sec.getByText(/размер неизвестен/)).toBeInTheDocument(); expect(sec.getByRole("button", { name: "Скачать" })).toBeInTheDocument(); @@ -546,7 +546,7 @@ describe("вложения в карточке", () => { it("плашка под комментарием и кнопка в тексте", async () => { hub({}); - open("/issues/AURORA-1650"); + open("/jira/issues/AURORA-1650"); const c = within(await screen.findByTestId("comments")); expect(c.getByRole("button", { name: "скрин 1.png · 234,4 КБ" })).toBeInTheDocument(); expect(c.getByRole("button", { name: "скрин 1.png" })).toBeInTheDocument(); @@ -556,7 +556,7 @@ describe("вложения в карточке", () => { it("«Загружается…» на всех кнопках одного вложения; чужие не затронуты", async () => { hub({}, (_m, url) => url.pathname === "/attachments/31337/fetch" || url.pathname === "/attachments/31337" ? json(200, { attachment_id: "31337", state: "fetching", error: null, command_id: 1, batch_id: 1 }) : undefined); - open("/issues/AURORA-1650"); + open("/jira/issues/AURORA-1650"); await userEvent.click(within(await screen.findByTestId("comments")).getByRole("button", { name: "скрин 1.png · 234,4 КБ" })); expect(await screen.findAllByText("Загружается…")).toHaveLength(3); for (const b of screen.getAllByText("Загружается…")) expect(b.closest("button")).toBeDisabled(); @@ -566,7 +566,7 @@ describe("вложения в карточке", () => { it("ошибка хаба — текст и «Повторить» под вложением", async () => { const f = hub({}, (_m, url) => url.pathname === "/attachments/31337/fetch" ? json(409, { error: "agent_too_old", detail: "агент v13 не умеет скачивать вложения — нужен v14" }) : undefined); - open("/issues/AURORA-1650"); + open("/jira/issues/AURORA-1650"); const sec = within(await screen.findByTestId("attachments")); await userEvent.click(sec.getAllByRole("button", { name: "Скачать" })[1]); expect(await sec.findByText("агент v13 не умеет скачивать вложения — нужен v14")).toBeInTheDocument(); diff --git a/web/src/modules/jira/issues/IssueLink.tsx b/web/src/modules/jira/issues/IssueLink.tsx index fd484ac..b214b55 100644 --- a/web/src/modules/jira/issues/IssueLink.tsx +++ b/web/src/modules/jira/issues/IssueLink.tsx @@ -2,7 +2,7 @@ import type { ReactNode } from "react"; import { Link, useLocation, useNavigate } from "react-router"; import { openCardState } from "./modal.ts"; -const cardPath = (key: string) => `/issues/${encodeURIComponent(key)}`; +const cardPath = (key: string) => `/jira/issues/${encodeURIComponent(key)}`; // переход к карточке задачи: окно поверх текущей страницы (или смена содержимого открытого окна) — как у ссылки-ключа export function useOpenIssue(): (key: string) => void { diff --git a/web/src/modules/jira/issues/IssueModal.test.tsx b/web/src/modules/jira/issues/IssueModal.test.tsx index 02f9f15..c58f40e 100644 --- a/web/src/modules/jira/issues/IssueModal.test.tsx +++ b/web/src/modules/jira/issues/IssueModal.test.tsx @@ -33,27 +33,27 @@ describe("карточка окном", () => { it("из списка: окно поверх списка, фон не пересоздаётся, ✕ возвращает со старым фильтром, «назад» не открывает карточку", async () => { sessionStorage.setItem("sdlc-hub-issue-filters", "1"); // панель «Фильтры» раскрыта - const router = at(["/app/board", "/app/issues?sprint=AWPCA_Спринт 2"]); + const router = at(["/app/jira/board", "/app/jira/issues?sprint=AWPCA_Спринт 2"]); render(); const table = await screen.findByRole("table"); expect(screen.getByLabelText("Спринт")).toHaveValue("AWPCA_Спринт 2"); await userEvent.click(await within(table).findByRole("link", { name: "AURORA-9002" })); - expect(router.state.location.pathname).toBe("/app/issues/AURORA-9002"); + expect(router.state.location.pathname).toBe("/app/jira/issues/AURORA-9002"); expect(await within(dialog()).findByRole("heading", { name: /AURORA-9002/ })).toBeInTheDocument(); expect(within(screen.getByRole("main")).getByRole("table")).toBe(table); // таблица команд карточки — в окне, не в main // фон не пересоздаётся expect(screen.getByLabelText("Спринт")).toHaveValue("AWPCA_Спринт 2"); // фильтр фона — из его адреса expect(screen.getByRole("link", { name: "Задачи" })).toHaveAttribute("aria-current", "page"); await userEvent.click(within(dialog()).getByRole("button", { name: "Закрыть" })); await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); - expect(router.state.location.pathname).toBe("/app/issues"); + expect(router.state.location.pathname).toBe("/app/jira/issues"); expect(new URLSearchParams(router.state.location.search).get("sprint")).toBe("AWPCA_Спринт 2"); expect(within(screen.getByRole("main")).getByRole("table")).toBe(table); // таблица команд карточки — в окне, не в main await act(async () => { await router.navigate(-1); }); - expect(router.state.location.pathname).toBe("/app/board"); + expect(router.state.location.pathname).toBe("/app/jira/board"); }); it("переход по связи меняет окно, «назад» возвращает прежнюю карточку, Esc закрывает всё", async () => { - const router = at(["/app/issues"]); + const router = at(["/app/jira/issues"]); render(); await userEvent.click(await screen.findByRole("link", { name: "AURORA-9001" })); await userEvent.click(await within(dialog()).findByRole("link", { name: "AURORA-9002" })); @@ -64,21 +64,21 @@ describe("карточка окном", () => { await within(dialog()).findByRole("heading", { name: /AURORA-9002/ }); fireEvent(dialog(), new Event("cancel", { cancelable: true })); await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); - expect(router.state.location.pathname).toBe("/app/issues"); + expect(router.state.location.pathname).toBe("/app/jira/issues"); }); it("щелчок по фону окна закрывает его", async () => { - const router = at(["/app/issues"]); + const router = at(["/app/jira/issues"]); render(); await userEvent.click(await screen.findByRole("link", { name: "AURORA-9001" })); await within(dialog()).findByRole("heading", { name: /AURORA-9001/ }); await userEvent.click(dialog()); // цель — сам , не содержимое await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); - expect(router.state.location.pathname).toBe("/app/issues"); + expect(router.state.location.pathname).toBe("/app/jira/issues"); }); it("I-2: выделение текста внутри окна, отпущенное над фоном, окно не закрывает", async () => { - const router = at(["/app/issues"]); + const router = at(["/app/jira/issues"]); render(); await userEvent.click(await screen.findByRole("link", { name: "AURORA-9001" })); const title = await within(dialog()).findByRole("heading", { name: /AURORA-9001/ }); @@ -86,11 +86,11 @@ describe("карточка окном", () => { fireEvent.pointerUp(dialog()); fireEvent.click(dialog()); // …отпустили над фоном: click — на expect(screen.getByRole("dialog")).toBeInTheDocument(); - expect(router.state.location.pathname).toBe("/app/issues/AURORA-9001"); + expect(router.state.location.pathname).toBe("/app/jira/issues/AURORA-9001"); }); it("с доски: окно поверх доски, раздел «Доска связей» активен, после закрытия доска та же", async () => { - const router = at(["/app/board"]); + const router = at(["/app/jira/board"]); render(); const ilink = await screen.findByRole("link", { name: "Открыть карточку AURORA-9001" }); const boardEl = screen.getByTestId("page-board"); @@ -99,34 +99,34 @@ describe("карточка окном", () => { expect(screen.getByTestId("page-board")).toBe(boardEl); expect(screen.getByRole("link", { name: "Доска связей" })).toHaveAttribute("aria-current", "page"); await userEvent.click(within(dialog()).getByRole("button", { name: "Закрыть" })); - await waitFor(() => expect(router.state.location.pathname).toBe("/app/board")); + await waitFor(() => expect(router.state.location.pathname).toBe("/app/jira/board")); expect(screen.getByTestId("page-board")).toBe(boardEl); }); it("прямой адрес: под окном список задач, ✕ ведёт в список заменой записи", async () => { - const router = at(["/app/board", "/app/issues/AURORA-9001"]); + const router = at(["/app/jira/board", "/app/jira/issues/AURORA-9001"]); render(); expect(await within(await screen.findByRole("dialog")).findByRole("heading", { name: /AURORA-9001/ })).toBeInTheDocument(); expect(await screen.findByTestId("page-issues")).toBeInTheDocument(); await waitFor(() => expect(router.state.location.state).toMatchObject({ depth: 0, direct: true })); await userEvent.click(within(dialog()).getByRole("button", { name: "Закрыть" })); - await waitFor(() => expect(router.state.location.pathname).toBe("/app/issues")); + await waitFor(() => expect(router.state.location.pathname).toBe("/app/jira/issues")); await act(async () => { await router.navigate(-1); }); - expect(router.state.location.pathname).toBe("/app/board"); // не карточка + expect(router.state.location.pathname).toBe("/app/jira/board"); // не карточка }); it("прямой адрес и переход по связи: закрытие возвращает в список, а не на первую карточку", async () => { - const router = at(["/app/issues/AURORA-9001"]); + const router = at(["/app/jira/issues/AURORA-9001"]); render(); await userEvent.click(await within(await screen.findByRole("dialog")).findByRole("link", { name: "AURORA-9002" })); await within(dialog()).findByRole("heading", { name: /AURORA-9002/ }); fireEvent(dialog(), new Event("cancel", { cancelable: true })); - await waitFor(() => expect(router.state.location.pathname).toBe("/app/issues")); + await waitFor(() => expect(router.state.location.pathname).toBe("/app/jira/issues")); await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); }); it("Esc с несохранённой заметкой спрашивает; «Остаться» оставляет окно с текстом", async () => { - const router = at(["/app/issues"]); + const router = at(["/app/jira/issues"]); render(); await userEvent.click(await screen.findByRole("link", { name: "AURORA-9001" })); await userEvent.click(await within(dialog()).findByRole("button", { name: "Править" })); @@ -135,12 +135,12 @@ describe("карточка окном", () => { fireEvent(dialog(), new Event("cancel", { cancelable: true })); expect(await within(dialog()).findByText("Есть несохранённые правки заметки. Уйти без сохранения?")).toBeInTheDocument(); await userEvent.click(within(dialog()).getByRole("button", { name: "Остаться" })); - expect(router.state.location.pathname).toBe("/app/issues/AURORA-9001"); + expect(router.state.location.pathname).toBe("/app/jira/issues/AURORA-9001"); expect(within(dialog()).getByLabelText("Заметка владельца")).toHaveValue("черновик"); }); it("T4.1: отказ от закрытия («Остаться») сбрасывает ожидание закрытия: «назад» на запись прямого адреса не уводит в список", async () => { - const router = at(["/app/issues/AURORA-9001"]); + const router = at(["/app/jira/issues/AURORA-9001"]); render(); await userEvent.click(await within(await screen.findByRole("dialog")).findByRole("link", { name: "AURORA-9002" })); await within(dialog()).findByRole("heading", { name: /AURORA-9002/ }); @@ -152,7 +152,7 @@ describe("карточка окном", () => { await userEvent.click(await within(dialog()).findByRole("button", { name: "Отменить правки" })); await act(async () => { await router.navigate(-1); }); expect(await within(dialog()).findByRole("heading", { name: /AURORA-9001/ })).toBeInTheDocument(); - expect(router.state.location.pathname).toBe("/app/issues/AURORA-9001"); + expect(router.state.location.pathname).toBe("/app/jira/issues/AURORA-9001"); }); it("T4.4: у окна есть имя в состоянии загрузки и 404", async () => { @@ -162,10 +162,10 @@ describe("карточка окном", () => { if (url.pathname === "/issues/AURORA-2") return json(404, { detail: "нет" }); return json(200, {}); }); - const r1 = render(); + const r1 = render(); expect(await screen.findByRole("dialog", { name: "AURORA-1" })).toBeInTheDocument(); r1.unmount(); - render(); + render(); expect(await screen.findByRole("dialog", { name: "AURORA-2" })).toBeInTheDocument(); }); }); diff --git a/web/src/modules/jira/issues/IssuesPage.test.tsx b/web/src/modules/jira/issues/IssuesPage.test.tsx index c9a3451..989c9f9 100644 --- a/web/src/modules/jira/issues/IssuesPage.test.tsx +++ b/web/src/modules/jira/issues/IssuesPage.test.tsx @@ -32,7 +32,7 @@ describe("список задач", () => { it("фильтры из адреса уходят в GET /issues; ключ ведёт на карточку", async () => { const fetch = hub(() => [row(1)]); renderWithProviders(, { route: "/issues?sprint=AWPCA_Спринт 2&neighbor=1", path: "/issues" }); - expect(await screen.findByRole("link", { name: "AURORA-9001" })).toHaveAttribute("href", "/issues/AURORA-9001"); + expect(await screen.findByRole("link", { name: "AURORA-9001" })).toHaveAttribute("href", "/jira/issues/AURORA-9001"); const q = issuesCalls(fetch)[0].searchParams; expect(q.get("project")).toBe("AWPCA"); expect(q.get("sprint")).toBe("AWPCA_Спринт 2"); @@ -325,7 +325,7 @@ describe("список задач", () => { hub(() => [row(1)]); const { router } = renderWithProviders(, { route: "/issues", path: "/issues" }); await userEvent.click(await screen.findByRole("cell", { name: "Задача 1" })); - await waitFor(() => expect(router.state.location.pathname).toBe("/issues/AURORA-9001")); + await waitFor(() => expect(router.state.location.pathname).toBe("/jira/issues/AURORA-9001")); }); it("выделенный текст в строке — без перехода", async () => { diff --git a/web/src/modules/jira/issues/modal.test.ts b/web/src/modules/jira/issues/modal.test.ts index 255902b..b7e18db 100644 --- a/web/src/modules/jira/issues/modal.test.ts +++ b/web/src/modules/jira/issues/modal.test.ts @@ -1,26 +1,31 @@ import { describe, expect, it } from "vitest"; -import { BOARD_CARD_STATE, cardState, LIST_BACKGROUND, openCardState } from "./modal.ts"; +import { BOARD_CARD_STATE, cardState, listBackground, openCardState } from "./modal.ts"; describe("состояние окна в истории", () => { + it("список под прямым адресом карточки берёт проект из её адреса", () => { + expect(listBackground("?project=AWPCA")).toEqual({ pathname: "/jira/issues", search: "?project=AWPCA" }); + expect(listBackground("")).toEqual({ pathname: "/jira/issues", search: "" }); + }); it("со страницы — фон = адрес страницы, глубина 1", () => { - expect(openCardState({ pathname: "/issues", search: "?sprint=S", state: null })) - .toEqual({ background: { pathname: "/issues", search: "?sprint=S" }, depth: 1, direct: false }); + expect(openCardState({ pathname: "/jira/issues", search: "?sprint=S", state: null })) + .toEqual({ background: { pathname: "/jira/issues", search: "?sprint=S" }, depth: 1, direct: false }); }); it("из окна — тот же фон, глубина + 1, признак прямого адреса сохраняется", () => { + const LIST_BACKGROUND = listBackground(""); const s = { background: LIST_BACKGROUND, depth: 0, direct: true }; - expect(openCardState({ pathname: "/issues/A-1", search: "", state: s })).toEqual({ background: LIST_BACKGROUND, depth: 1, direct: true }); - expect(openCardState({ pathname: "/issues/A-2", search: "", state: BOARD_CARD_STATE })) - .toEqual({ background: { pathname: "/board", search: "" }, depth: 2, direct: false }); + expect(openCardState({ pathname: "/jira/issues/A-1", search: "", state: s })).toEqual({ background: LIST_BACKGROUND, depth: 1, direct: true }); + expect(openCardState({ pathname: "/jira/issues/A-2", search: "", state: BOARD_CARD_STATE })) + .toEqual({ background: { pathname: "/jira/board", search: "" }, depth: 2, direct: false }); }); it("чужое или битое state — не состояние окна", () => { expect(cardState(null)).toBeNull(); - expect(cardState({ background: "/issues", depth: 1 })).toBeNull(); - expect(cardState({ background: { pathname: "/issues", search: "" }, depth: "1" })).toBeNull(); - expect(cardState({ background: { pathname: "/issues", search: "" }, depth: 1 })) - .toEqual({ background: { pathname: "/issues", search: "" }, depth: 1, direct: false }); + expect(cardState({ background: "/jira/issues", depth: 1 })).toBeNull(); + expect(cardState({ background: { pathname: "/jira/issues", search: "" }, depth: "1" })).toBeNull(); + expect(cardState({ background: { pathname: "/jira/issues", search: "" }, depth: 1 })) + .toEqual({ background: { pathname: "/jira/issues", search: "" }, depth: 1, direct: false }); }); it("глубина — только целое неотрицательное, иначе 0", () => { - const bg = { pathname: "/issues", search: "" }; + const bg = { pathname: "/jira/issues", search: "" }; for (const depth of [-1, 1.5, NaN, Infinity]) expect(cardState({ background: bg, depth })?.depth).toBe(0); expect(cardState({ background: bg, depth: 3 })?.depth).toBe(3); }); diff --git a/web/src/modules/jira/issues/modal.ts b/web/src/modules/jira/issues/modal.ts index 6c66b9c..ecd846c 100644 --- a/web/src/modules/jira/issues/modal.ts +++ b/web/src/modules/jira/issues/modal.ts @@ -3,9 +3,14 @@ export interface Background { pathname: string; search: string } export interface CardState { background: Background; depth: number; direct: boolean } -export const LIST_BACKGROUND: Background = { pathname: "/issues", search: "" }; -// доска не хранит состояние в адресе: «ⓘ» всегда открывает окно поверх /board -export const BOARD_CARD_STATE: CardState = { background: { pathname: "/board", search: "" }, depth: 1, direct: false }; +export const ISSUES_PATH = "/jira/issues"; +// список под окном карточки по прямому адресу: проект берётся из адреса карточки, чтобы список и закрытие остались в нём +export function listBackground(cardSearch: string): Background { + const project = new URLSearchParams(cardSearch).get("project"); + return { pathname: ISSUES_PATH, search: project ? "?" + new URLSearchParams({ project }).toString() : "" }; +} +// доска не хранит состояние в адресе: «ⓘ» всегда открывает окно поверх /jira/board +export const BOARD_CARD_STATE: CardState = { background: { pathname: "/jira/board", search: "" }, depth: 1, direct: false }; export function cardState(s: unknown): CardState | null { const c = s as { background?: { pathname?: unknown; search?: unknown }; depth?: unknown; direct?: unknown } | null; diff --git a/web/src/modules/jira/issues/overlay.tsx b/web/src/modules/jira/issues/overlay.tsx index 47a1834..ad3b173 100644 --- a/web/src/modules/jira/issues/overlay.tsx +++ b/web/src/modules/jira/issues/overlay.tsx @@ -3,16 +3,17 @@ import { matchPath, useLocation, useNavigate } from "react-router"; import type { Overlay } from "../../types.ts"; import { CloseAbortContext } from "./closeAbort.ts"; import { IssueModal } from "./IssueModal.tsx"; -import { cardState, LIST_BACKGROUND } from "./modal.ts"; +import { cardState, ISSUES_PATH, listBackground } from "./modal.ts"; -// окно карточки задачи /issues/{key} поверх страницы под ним (design 10–12 веб-приложения) +// окно карточки задачи /jira/issues/{key} поверх страницы под ним (design 10–12 веб-приложения) export function useIssueOverlay(): Overlay { const location = useLocation(); const navigate = useNavigate(); - const issueKey = matchPath("/issues/:key", location.pathname)?.params.key ?? null; + const issueKey = matchPath(`${ISSUES_PATH}/:key`, location.pathname)?.params.key ?? null; const card = issueKey ? cardState(location.state) : null; - const bgPath = issueKey ? (card?.background ?? LIST_BACKGROUND).pathname : null; - const bgSearch = issueKey ? (card?.background ?? LIST_BACKGROUND).search : ""; + const direct = listBackground(location.search); + const bgPath = issueKey ? (card?.background ?? direct).pathname : null; + const bgSearch = issueKey ? (card?.background ?? direct).search : ""; // адрес страницы под окном; оболочка передаёт его в useRoutes всегда (и без окна), чтобы дерево страницы не менялось при // открытии окна и useSearchParams внутри неё видел её собственный адрес (design 10) const pageLocation = useMemo(() => bgPath === null ? null : { pathname: bgPath, search: bgSearch, hash: "", state: null, key: "card-background" }, @@ -21,7 +22,7 @@ export function useIssueOverlay(): Overlay { // прямой адрес карточки: запись истории получает state окна поверх списка задач (design 11) useEffect(() => { if (issueKey && !card) { - void navigate(location.pathname + location.search, { replace: true, state: { background: LIST_BACKGROUND, depth: 0, direct: true } }); + void navigate(location.pathname + location.search, { replace: true, state: { background: listBackground(location.search), depth: 0, direct: true } }); } }, [issueKey, card, location.pathname, location.search, navigate]); @@ -30,14 +31,14 @@ export function useIssueOverlay(): Overlay { useEffect(() => { if (closingDirect.current && card?.direct && card.depth === 0) { closingDirect.current = false; - void navigate("/issues", { replace: true }); + void navigate(ISSUES_PATH + direct.search, { replace: true }); } - }, [card, navigate]); + }, [card, navigate, direct.search]); const close = useCallback(() => { - if (!card || card.depth === 0) { void navigate("/issues", { replace: true }); return; } + if (!card || card.depth === 0) { void navigate(ISSUES_PATH + direct.search, { replace: true }); return; } closingDirect.current = card.direct; void navigate(-card.depth); - }, [card, navigate]); + }, [card, navigate, direct.search]); const abortClose = useCallback(() => { closingDirect.current = false; }, []); diff --git a/web/src/modules/jira/issues/wiki/WikiText.test.tsx b/web/src/modules/jira/issues/wiki/WikiText.test.tsx index 7abd1e0..f525d9f 100644 --- a/web/src/modules/jira/issues/wiki/WikiText.test.tsx +++ b/web/src/modules/jira/issues/wiki/WikiText.test.tsx @@ -38,7 +38,7 @@ describe("WikiText", () => { it("ключ задачи проекта — ссылка на карточку; без projectKey — текст", () => { const { unmount } = show("см. AURORA-1445", "AURORA"); - expect(screen.getByRole("link", { name: "AURORA-1445" })).toHaveAttribute("href", "/issues/AURORA-1445"); + expect(screen.getByRole("link", { name: "AURORA-1445" })).toHaveAttribute("href", "/jira/issues/AURORA-1445"); unmount(); show("см. AURORA-1445"); expect(screen.queryByRole("link")).toBeNull(); diff --git a/web/src/modules/modules.test.tsx b/web/src/modules/modules.test.tsx index d90428d..a801327 100644 --- a/web/src/modules/modules.test.tsx +++ b/web/src/modules/modules.test.tsx @@ -5,13 +5,13 @@ import { PAGE_ROUTES } from "../shell/pages.tsx"; import { MODULES } from "./index.ts"; describe("сборка оболочки из модулей", () => { - it("страницы: адреса прежние плюс вики, NotFound последним", () => { - expect(PAGE_ROUTES.map((r) => r.path)).toEqual(["/", "/board", "/issues", "/feed", "/wiki", "*"]); + it("страницы: «Обзор», адреса jira, перенаправления, вики, NotFound последним", () => { + expect(PAGE_ROUTES.map((r) => r.path)).toEqual(["/", "/jira/board", "/jira/issues", "/jira/feed", "/board", "/issues", "/issues/:key", "/feed", "/wiki", "*"]); }); - it("пункты панели: подписи и адреса прежние плюс вики", () => { + it("пункты панели: подписи прежние, адреса jira с префиксом, плюс вики", () => { expect(MODULES.flatMap((m) => m.nav).map((n) => [n.label, n.to])).toEqual([ - ["Доска связей", "/board"], ["Задачи", "/issues"], ["Лента", "/feed"], ["Вики", "/wiki"], + ["Доска связей", "/jira/board"], ["Задачи", "/jira/issues"], ["Лента", "/jira/feed"], ["Вики", "/wiki"], ]); }); @@ -42,7 +42,7 @@ describe("NotFound при повторе адреса", () => { const { NotFound: NF } = await import("../shell/NotFound.tsx"); const err = vi.spyOn(console, "error").mockImplementation(() => {}); render(); - expect(screen.getAllByRole("link")).toHaveLength(2); + expect(screen.getAllByRole("link")).toHaveLength(3); // «Обзор» + два раздела expect(err.mock.calls.flat().join(" ")).not.toMatch(/same key/); err.mockRestore(); vi.doUnmock("../modules/index.ts"); diff --git a/web/src/modules/types.ts b/web/src/modules/types.ts index 315fcfc..379b94a 100644 --- a/web/src/modules/types.ts +++ b/web/src/modules/types.ts @@ -5,6 +5,7 @@ import type { Batch, Project } from "../api/types.ts"; export type NavItem = { to: string; label: string; icon: ReactNode; + title?: string; // название страницы для заголовка вкладки; по умолчанию label padded?: boolean; // страница с полями (не во всю ширину): оболочка не ставит ей класс page--board; для адресов вне панели класс ставится keepSearch?: boolean; // при смене проекта строка запроса страницы сохраняется (параметры не проектные); иначе сбрасывается }; diff --git a/web/src/projects/projects.test.tsx b/web/src/projects/projects.test.tsx index 4cf292b..a014e39 100644 --- a/web/src/projects/projects.test.tsx +++ b/web/src/projects/projects.test.tsx @@ -47,7 +47,7 @@ describe("проект в приложении", () => { it("первый вход после выкатки: проект подставляется в адрес и запоминается", async () => { const h = hub(); - const router = open("/app/board"); + const router = open("/app/jira/board"); await waitFor(() => expect(router.state.location.search).toBe("?project=AWPCA")); expect(router.state.historyAction).toBe("REPLACE"); await waitFor(() => expect(h.get("/board").length).toBeGreaterThan(0)); @@ -57,15 +57,15 @@ describe("проект в приложении", () => { it("переключение проекта: доска, ссылка «Задачи», список и варианты фильтров", async () => { const h = hub(); - const router = open("/app/board?project=AWPCA"); + const router = open("/app/jira/board?project=AWPCA"); await screen.findByTestId("page-board"); await userEvent.selectOptions(screen.getByRole("combobox", { name: "Проект" }), "STOCKS_DRRK"); expect(router.state.historyAction).toBe("PUSH"); await waitFor(() => expect(h.get("/board").some((c) => c.url.searchParams.get("project") === "STOCKS_DRRK")).toBe(true)); const link = screen.getByRole("link", { name: "Задачи" }); - expect(link).toHaveAttribute("href", "/app/issues?project=STOCKS_DRRK"); + expect(link).toHaveAttribute("href", "/app/jira/issues?project=STOCKS_DRRK"); await userEvent.click(link); - expect(router.state.location.pathname + router.state.location.search).toBe("/app/issues?project=STOCKS_DRRK"); + expect(router.state.location.pathname + router.state.location.search).toBe("/app/jira/issues?project=STOCKS_DRRK"); await screen.findByTestId("page-issues"); await waitFor(() => expect(h.get("/issues").some((c) => c.url.searchParams.get("project") === "STOCKS_DRRK")).toBe(true)); expect(h.get("/board").filter((c) => c.url.searchParams.get("project") === "STOCKS_DRRK").length).toBeGreaterThan(0); @@ -74,14 +74,14 @@ describe("проект в приложении", () => { it("запомненный проект подставляется в адрес", async () => { localStorage.setItem(REMEMBER_KEY, "STOCKS_DRRK"); hub(); - const router = open("/app/issues"); + const router = open("/app/jira/issues"); await waitFor(() => expect(router.state.location.search).toBe("?project=STOCKS_DRRK")); expect(router.state.historyAction).toBe("REPLACE"); }); it("неизвестный проект в адресе: сообщение и список проектов, без GET /issues", async () => { const h = hub(); - open("/app/issues?project=NOPE"); + open("/app/jira/issues?project=NOPE"); expect(await screen.findByText(/проект NOPE не найден/)).toBeInTheDocument(); expect(screen.getByTestId("project-unknown")).toHaveTextContent("Выберите проект в списке «Проект» вверху."); expect(screen.getByRole("combobox", { name: "Проект" })).toBeInTheDocument(); @@ -90,7 +90,7 @@ describe("проект в приложении", () => { it("проектов нет: приглашение создать, без GET /board; щелчок открывает окно", async () => { const h = hub([]); - open("/app/board"); + open("/app/jira/board"); await userEvent.click(await screen.findByRole("button", { name: "+ Новый проект…" })); expect(screen.getByRole("dialog", { name: "Новый проект" })).toHaveAttribute("open"); expect(h.get("/board")).toHaveLength(0); @@ -103,7 +103,7 @@ describe("проект в приложении", () => { "GET /projects": () => json(200, { projects: created ? [A, S] : [A] }), // блок состояния берёт проект из списка "GET /batches/7": () => json(200, { id: 7, author: "viewer", status: "queued", remaining_seconds: 5, counts: {}, commands: [{ type: "refresh", status: "queued" }] }), }); - const router = open("/app/board?project=AWPCA"); + const router = open("/app/jira/board?project=AWPCA"); await screen.findByTestId("page-board"); const listed = h.get("/batches").length; await userEvent.selectOptions(screen.getByRole("combobox", { name: "Проект" }), "+ Новый проект…"); @@ -122,7 +122,7 @@ describe("проект в приложении", () => { it("с номером доски board_id уходит числом", async () => { const h = hub([A], { "POST /projects": () => json(201, { project: S, batch_id: 7 }) }); - open("/app/board?project=AWPCA"); + open("/app/jira/board?project=AWPCA"); await screen.findByTestId("page-board"); await userEvent.selectOptions(screen.getByRole("combobox", { name: "Проект" }), "+ Новый проект…"); const dlg = screen.getByRole("dialog", { name: "Новый проект" }); @@ -137,7 +137,7 @@ describe("проект в приложении", () => { it("метка занята: окно открыто, текст ошибки хаба рядом с «Создать»", async () => { hub([A], { "POST /projects": () => json(409, { error: "project_exists", detail: "label: проект с такой меткой уже есть: AWPCA" }) }); - open("/app/board?project=AWPCA"); + open("/app/jira/board?project=AWPCA"); await screen.findByTestId("page-board"); await userEvent.selectOptions(screen.getByRole("combobox", { name: "Проект" }), "+ Новый проект…"); const dlg = screen.getByRole("dialog", { name: "Новый проект" }); @@ -155,7 +155,7 @@ describe("проект в приложении", () => { "POST /projects": () => json(201, { project: S, batch_id: 7 }), "GET /batches/7": () => json(200, { id: 7, author: "viewer", status: "queued", remaining_seconds: 5, counts: {}, commands: [{ type: "refresh", status: "queued" }] }), }); - const router = open("/app/board?project=AWPCA"); + const router = open("/app/jira/board?project=AWPCA"); await screen.findByTestId("page-board"); await userEvent.selectOptions(screen.getByRole("combobox", { name: "Проект" }), "+ Новый проект…"); const dlg = screen.getByRole("dialog", { name: "Новый проект" }); @@ -172,7 +172,7 @@ describe("проект в приложении", () => { let release!: () => void; const gate = new Promise((r) => { release = r; }); hub([A], { "POST /projects": async () => { await gate; return json(201, { project: S, batch_id: 7 }); } }); - open("/app/board?project=AWPCA"); + open("/app/jira/board?project=AWPCA"); await screen.findByTestId("page-board"); await userEvent.selectOptions(screen.getByRole("combobox", { name: "Проект" }), "+ Новый проект…"); const dlg = screen.getByRole("dialog", { name: "Новый проект" }); @@ -189,11 +189,62 @@ describe("проект в приложении", () => { it("карточка по прямому адресу: выбор проекта не переписывает адрес и не закрывает окно", async () => { const h = hub(); - const router = open("/app/issues/AURORA-9001"); + const router = open("/app/jira/issues/AURORA-9001"); expect(await screen.findByTestId("issue-card")).toBeInTheDocument(); await waitFor(() => expect(h.get("/issues").some((c) => c.url.searchParams.get("project") === "AWPCA")).toBe(true)); - expect(router.state.location.pathname).toBe("/app/issues/AURORA-9001"); + expect(router.state.location.pathname).toBe("/app/jira/issues/AURORA-9001"); expect(router.state.location.search).toBe(""); expect(screen.getByTestId("issue-card")).toBeInTheDocument(); }); }); + +describe("адреса модулей и заголовок вкладки", () => { + beforeEach(() => { setToken("tok"); localStorage.clear(); }); + const W = { ...A, wiki_root: null }; + const WITH_WIKI = { ...A, wiki_root: "DOC-1", wiki_synced_at: null, wiki_sync_error: null }; + + it("legacy card address: /issues/KEY?project=… → /jira/issues/KEY, окно поверх списка, закрытие — список с проектом", async () => { + hub(); + const router = open("/app/issues/AURORA-9001?project=AWPCA"); + expect(await screen.findByTestId("issue-card")).toBeInTheDocument(); + expect(router.state.location.pathname + router.state.location.search).toBe("/app/jira/issues/AURORA-9001?project=AWPCA"); + expect(await screen.findByTestId("page-issues")).toBeInTheDocument(); + await userEvent.click(within(screen.getByRole("dialog")).getByRole("button", { name: /Закрыть/ })); + await waitFor(() => expect(router.state.location.pathname + router.state.location.search).toBe("/app/jira/issues?project=AWPCA")); + }); + + it("прежние /board, /issues, /feed перенаправляют с сохранением search, replace", async () => { + hub(); + const router = open("/app/board?project=STOCKS_DRRK&x=1"); + await waitFor(() => expect(router.state.location.pathname).toBe("/app/jira/board")); + expect(router.state.location.search).toBe("?project=STOCKS_DRRK&x=1"); + expect(router.state.historyAction).toBe("REPLACE"); + }); + + it("смена проекта на «Вики» у проекта без вики остаётся на «Вики»", async () => { + hub([W, S], { "GET /wiki/status": () => json(409, { error: "no_root", detail: "нет корня" }) }); + const router = open("/app/wiki?project=AWPCA"); + await screen.findByLabelText("Корень вики (DOC-… или ссылка)"); + await screen.findByRole("option", { name: /STOCKS_DRRK/ }); + await userEvent.selectOptions(screen.getByRole("combobox", { name: "Проект" }), "STOCKS_DRRK"); + await waitFor(() => expect(router.state.location.pathname + router.state.location.search).toBe("/app/wiki?project=STOCKS_DRRK")); + }); + + it("смена проекта на неизвестном адресе ведёт на «Обзор»", async () => { + hub(); + const router = open("/app/nowhere?project=AWPCA"); + await screen.findByRole("heading", { name: "Страница не найдена" }); + await screen.findByRole("option", { name: /STOCKS_DRRK/ }); + await userEvent.selectOptions(screen.getByRole("combobox", { name: "Проект" }), "STOCKS_DRRK"); + await waitFor(() => expect(router.state.location.pathname + router.state.location.search).toMatch(/^\/app\/?\?project=STOCKS_DRRK$/)); + }); + + it("заголовок вкладки: «Вики · AWPCA · SDLC Hub» и «Обзор · AWPCA · SDLC Hub»", async () => { + hub([WITH_WIKI], { "GET /wiki/tree": () => json(200, { nodes: [] }), "GET /wiki/status": () => json(200, {}) }); + const router = open("/app/wiki?project=AWPCA"); + await waitFor(() => expect(document.title).toBe("Вики · AWPCA · SDLC Hub")); + void router.navigate("/?project=AWPCA"); + await waitFor(() => expect(document.title).toBe("Обзор · AWPCA · SDLC Hub")); + }); +}); + diff --git a/web/src/shell/Layout.tsx b/web/src/shell/Layout.tsx index 6b7a806..7faff22 100644 --- a/web/src/shell/Layout.tsx +++ b/web/src/shell/Layout.tsx @@ -1,18 +1,19 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { useLocation, useNavigate, useRoutes } from "react-router"; import { useProjects } from "../api/queries.ts"; -import { MODULES, useOverlays } from "../modules/index.ts"; +import { useOverlays } from "../modules/index.ts"; import { rememberedProject, rememberProject, resolveProject, withProject } from "../projects/current.ts"; import { NewProjectDialog } from "../projects/NewProjectDialog.tsx"; import { ProjectContext, type ProjectCtx, type ProjectState } from "../projects/ProjectContext.ts"; import { ActivityProvider } from "./activity/ActivityProvider.tsx"; import { useTrackBatch } from "./activity/activity.ts"; import { ErrorBoundary } from "./ErrorBoundary.tsx"; +import { NAV } from "./nav.tsx"; import { PAGE_ROUTES } from "./pages.tsx"; import { Sidebar } from "./Sidebar.tsx"; +import { useDocumentTitle } from "./useDocumentTitle.ts"; import "./shell.css"; -const NAV = MODULES.flatMap((m) => m.nav); const NAV_PATHS: readonly string[] = NAV.map((n) => n.to); export function Layout() { @@ -36,6 +37,8 @@ export function Layout() { const fromUrl = state.kind === "ok" && state.fromUrl; const section = NAV_PATHS.includes(at) ? at : null; const onPage = section !== null; + const navItem = NAV.find((n) => n.to === section); + useDocumentTitle(navItem ? navItem.title ?? navItem.label : "Страница не найдена", okLabel); useEffect(() => { if (okLabel) rememberProject(okLabel); }, [okLabel]); // выбранный проект записывается в адрес без новой записи истории; с открытой карточкой адрес не трогаем (design 10) useEffect(() => { @@ -47,8 +50,8 @@ export function Layout() { const [newOpen, setNewOpen] = useState(false); // смена проекта: фильтры доски и списка — проектные и сбрасываются; период и виды ленты от проекта не зависят (R-1) const select = useCallback((label: string) => { - void navigate({ pathname: section ?? NAV_PATHS[0], search: withProject(NAV.find((n) => n.to === section)?.keepSearch ? pageLocation.search : "", label) }); - }, [navigate, section, pageLocation.search]); + void navigate({ pathname: section ?? "/", search: withProject(navItem?.keepSearch ? pageLocation.search : "", label) }); + }, [navigate, section, navItem?.keepSearch, pageLocation.search]); const ctx = useMemo(() => ({ state, projects: projects.data ?? [], select, openNew: () => setNewOpen(true) }), [state, projects.data, select]); @@ -57,7 +60,7 @@ export function Layout() {
-
n.to === section)?.padded ? "" : " page--board")}> +
{page}
diff --git a/web/src/shell/ModuleStatusBlock.test.tsx b/web/src/shell/ModuleStatusBlock.test.tsx new file mode 100644 index 0000000..1e3063e --- /dev/null +++ b/web/src/shell/ModuleStatusBlock.test.tsx @@ -0,0 +1,99 @@ +import { cleanup, render, screen, waitFor, within } from "@testing-library/react"; +import { createMemoryRouter } from "react-router"; +import { beforeEach, describe, expect, it } from "vitest"; +import { App } from "../App.tsx"; +import { setToken } from "../auth/token.ts"; +import { routes } from "../routes.tsx"; +import { json, mockFetch } from "../test/fetchMock.ts"; + +const BASE = { label: "AWPCA", name: "АРМ КА", board_id: null, board_type: null, created_at: "", issues_count: 9, + jira_synced_at: "2026-10-24T11:07:00", wiki_root: "DOC-1", wiki_synced_at: "2026-10-24T11:05:00", wiki_sync_error: null, wiki_pages: 28 }; +const AGENT = { online: true, last_seen_at: "2026-10-24T09:30:00", agent_version: 1, warning: null, update_hint: null, current_command_id: null, release: null }; + +function hub(project: Record = {}, agents: unknown[] = [{ module: "jira", ...AGENT }, { module: "wiki", ...AGENT }]) { + const paths: string[] = []; + mockFetch((method, url) => { + paths.push(`${method} ${url.pathname}`); + const k = `${method} ${url.pathname}`; + if (k === "GET /projects") return json(200, { projects: [{ ...BASE, ...project }] }); + if (k === "GET /agents") return json(200, { agents }); + if (k === "GET /batches") return json(200, { batches: [] }); + return json(404, { error: "not_found" }); + }); + return paths; +} +const open = (collapsed = false) => { + if (collapsed) localStorage.setItem("sdlc-hub-sidebar", "collapsed"); + render(); +}; +const bar = () => screen.getByRole("complementary", { name: "Панель приложения" }); +const rowNow = (module: string) => bar().querySelector(`[data-module="${module}"]`); +const row = (module: string) => { const r = rowNow(module); if (!r) throw new Error(`нет строки ${module}`); return r; }; + +describe("блок состояния модулей", () => { + beforeEach(() => { setToken("tok"); localStorage.clear(); cleanup(); }); + + it("две строки: название, точка с текстом агента, свежесть с охватом, значки обновления", async () => { + hub(); + open(); + await within(bar()).findByRole("button", { name: "Обновить из Jira — все проекты" }); + expect(within(row("jira")).getByText("Jira")).toBeInTheDocument(); + expect(within(row("wiki")).getByText("Вики")).toBeInTheDocument(); + expect(await within(row("jira")).findByText("агент на связи")).toBeInTheDocument(); + expect(await within(row("wiki")).findByText("агент на связи")).toBeInTheDocument(); + expect(within(row("jira")).getByText("данные на 24.10 11:07 · все проекты")).toBeInTheDocument(); + expect(within(row("wiki")).getByText("данные на 24.10 11:05 · проект")).toBeInTheDocument(); + expect(within(bar()).getByRole("button", { name: "Обновить вики — проект AWPCA" })).toBeInTheDocument(); + }); + + it("агент офлайн и «обновите агента» — текстом, предупреждение в подсказке", async () => { + hub({}, [{ module: "jira", ...AGENT, online: false }, { module: "wiki", ...AGENT, warning: "версия устарела" }]); + open(); + await waitFor(() => row("wiki")); + expect(await within(row("jira")).findByText("агент офлайн")).toBeInTheDocument(); + const upd = await within(row("wiki")).findByText("обновите агента"); + expect(upd.closest("[title]")).toHaveAttribute("title", expect.stringContaining("версия устарела")); + }); + + it("обновления ещё не было", async () => { + hub({ jira_synced_at: null }); + open(); + await waitFor(() => row("jira")); + expect(await within(row("jira")).findByText("обновления ещё не было")).toBeInTheDocument(); + }); + + it("ошибка синхронизации вики — текстом в строке «Вики», тоном ошибки", async () => { + hub({ wiki_sync_error: "403 Forbidden" }); + open(); + await waitFor(() => row("wiki")); + const err = await within(row("wiki")).findByText(/403 Forbidden/); + expect(err).toHaveClass("data-status__sync--error"); + }); + + it("no wiki row without root: одна строка, запросов /wiki/status и /board нет", async () => { + const paths = hub({ wiki_root: null, wiki_synced_at: null }); + open(); + await within(bar()).findByRole("button", { name: "Обновить из Jira — все проекты" }); + expect(rowNow("wiki")).toBeNull(); + expect(within(bar()).queryByRole("button", { name: /^Обновить вики/ })).toBeNull(); + expect(paths.filter((p) => p === "GET /wiki/status" || p === "GET /board")).toEqual([]); + }); + + it("живой регион role=status стоит в строке модуля постоянно, до появления события", async () => { + hub(); + open(); + await within(bar()).findByRole("button", { name: "Обновить из Jira — все проекты" }); + const st = within(row("jira")).getByRole("status"); + expect(st).toBeEmptyDOMElement(); + }); + + it("collapsed: по значку на модуль, подробности в title", async () => { + hub(); + open(true); + const jira = await within(bar()).findByRole("button", { name: "Обновить из Jira — все проекты" }); + await waitFor(() => expect(jira.getAttribute("title")).toMatch(/Jira · агент на связи · данные на 24\.10 11:07 · все проекты/)); + const wiki = within(bar()).getByRole("button", { name: "Обновить вики — проект AWPCA" }); + expect(wiki.getAttribute("title")).toMatch(/Вики · .*данные на 24\.10 11:05 · проект/); + expect(within(bar()).queryByText("данные на 24.10 11:07 · все проекты")).toBeNull(); + }); +}); diff --git a/web/src/shell/activity/StatusBlock.tsx b/web/src/shell/ModuleStatusBlock.tsx similarity index 51% rename from web/src/shell/activity/StatusBlock.tsx rename to web/src/shell/ModuleStatusBlock.tsx index b7e733c..f05558f 100644 --- a/web/src/shell/activity/StatusBlock.tsx +++ b/web/src/shell/ModuleStatusBlock.tsx @@ -1,17 +1,18 @@ -// ВРЕМЕННЫЙ блок состояния модулей (Task 3): по строке на модуль с данными для проекта. Task 4 заменяет его -// полноценным блоком панели («Состояние и обновление модулей»); здесь — данные контракта и прежние классы -import { useProjects } from "../../api/queries.ts"; -import type { Project } from "../../api/types.ts"; -import { MODULES } from "../../modules/index.ts"; -import type { HubModule } from "../../modules/types.ts"; -import { fmtStamp } from "../../ui/format.ts"; -import { IconButton, IconRefresh } from "../../ui/index.ts"; -import { useModuleActivity } from "./activity.ts"; -import { eventText } from "./events.ts"; +import { useProjects } from "../api/queries.ts"; +import type { Project } from "../api/types.ts"; +import { MODULES } from "../modules/index.ts"; +import type { HubModule } from "../modules/types.ts"; +import { fmtStamp } from "../ui/format.ts"; +import { IconButton, IconRefresh } from "../ui/index.ts"; +import { useModuleActivity } from "./activity/activity.ts"; +import { eventText } from "./activity/events.ts"; -const WITH_STATUS = MODULES.filter((m): m is HubModule & Required> => !!m.status); +type WithStatus = HubModule & Required>; +const WITH_STATUS = MODULES.filter((m): m is WithStatus => !!m.status); -function ModuleRow({ m, project, compact }: { m: (typeof WITH_STATUS)[number]; project: Project; compact: boolean }) { +// строка модуля: название, агент (точка + текст), значок обновления, свежесть, событие пакета. +// Живой регион role="status" стоит в строке постоянно и меняет только текст (T10.1 #94): вставка вместе с текстом не озвучивается +function ModuleRow({ m, project, variant }: { m: WithStatus; project: Project; variant: "expanded" | "collapsed" }) { const act = useModuleActivity(m.name); const { status } = m; const fresh = status.freshness(project); @@ -25,13 +26,14 @@ function ModuleRow({ m, project, compact }: { m: (typeof WITH_STATUS)[number]; p : fresh.at ? `данные на ${fmtStamp(fresh.at)} · ${scope}` : "обновления ещё не было"; const ev = act.event ? eventText(act.event, label ?? "") : null; const online = !!a && a.online && !a.warning && !a.update_hint; - const eventEl = {ev?.text}; const button = action && ( -
); } -// строки модулей, у которых есть данные для проекта -export function StatusBlock({ project, compact }: { project: string | null; compact: boolean }) { +// блок состояния модулей панели: по строке на модуль, у которого есть данные для проекта (у вики без корня строки нет) +export function ModuleStatusBlock({ project, variant }: { project: string | null; variant: "expanded" | "collapsed" }) { const p = useProjects().data?.find((x) => x.label === project); if (!p) return null; - return <>{WITH_STATUS.filter((m) => m.status.freshness(p).available).map((m) => )}; + return <>{WITH_STATUS.filter((m) => m.status.freshness(p).available).map((m) => )}; } -// I-1: на телефоне подсказок нет — событие пакета строкой под верхней панелью -function ModuleNotice({ m }: { m: (typeof WITH_STATUS)[number] }) { +// узкий экран: события пакетов модулей — строкой под верхней строкой; контейнер role="status" в DOM всегда (T10.1 #94) +function ModuleEvent({ m }: { m: WithStatus }) { const act = useModuleActivity(m.name); const ev = act.event ? eventText(act.event, m.status.refresh?.label ?? "") : null; - return ev ? : null; + return ev ?
{ev.text}
: null; } -export function TopbarNotice() { - return <>{WITH_STATUS.map((m) => )}; +export function ModuleEventStrip() { + return
{WITH_STATUS.map((m) => )}
; } diff --git a/web/src/shell/NotFound.tsx b/web/src/shell/NotFound.tsx index 7a98bc6..6d56a88 100644 --- a/web/src/shell/NotFound.tsx +++ b/web/src/shell/NotFound.tsx @@ -1,9 +1,7 @@ import { Fragment } from "react"; import { Link } from "react-router"; -import { MODULES } from "../modules/index.ts"; import { Notice } from "../ui/index.ts"; - -const NAV = MODULES.flatMap((m) => m.nav); +import { NAV } from "./nav.tsx"; export function NotFound() { return ( diff --git a/web/src/shell/OverviewPage.tsx b/web/src/shell/OverviewPage.tsx new file mode 100644 index 0000000..b21db96 --- /dev/null +++ b/web/src/shell/OverviewPage.tsx @@ -0,0 +1,10 @@ +import { PageHeader } from "../ui/index.ts"; + +// ВРЕМЕННАЯ заглушка «Обзора» (Task 4): Task 5 заменяет её страницей со сводками модулей +export function OverviewPage() { + return ( +
+ +
+ ); +} diff --git a/web/src/shell/Sidebar.test.tsx b/web/src/shell/Sidebar.test.tsx index c3ab326..865728d 100644 --- a/web/src/shell/Sidebar.test.tsx +++ b/web/src/shell/Sidebar.test.tsx @@ -45,19 +45,19 @@ describe("боковая панель", () => { it("разделы — ссылки «Доска связей» и «Задачи», активная с aria-current", async () => { hub(); - open("/app/issues?project=AWPCA"); + open("/app/jira/issues?project=AWPCA"); await screen.findByRole("link", { name: "Доска связей" }); - await waitFor(() => expect(screen.getByRole("link", { name: "Доска связей" })).toHaveAttribute("href", "/app/board?project=AWPCA")); + await waitFor(() => expect(screen.getByRole("link", { name: "Доска связей" })).toHaveAttribute("href", "/app/jira/board?project=AWPCA")); expect(screen.getByRole("link", { name: "Задачи" })).toHaveAttribute("aria-current", "page"); expect(screen.getByRole("link", { name: "Доска связей" })).not.toHaveAttribute("aria-current"); }); it("раздел «Лента» ведёт на /feed с проектом и отмечается как текущий", async () => { hub({ "GET /changes": () => json(200, { since: "", until: "", truncated: false, changes: [] }) }); - open("/app/feed?project=AWPCA"); + open("/app/jira/feed?project=AWPCA"); expect(await screen.findByTestId("page-feed")).toBeInTheDocument(); const nav = within(bar()).getByRole("navigation", { name: "Разделы" }); - await waitFor(() => expect(within(nav).getByRole("link", { name: "Лента" })).toHaveAttribute("href", "/app/feed?project=AWPCA")); + await waitFor(() => expect(within(nav).getByRole("link", { name: "Лента" })).toHaveAttribute("href", "/app/jira/feed?project=AWPCA")); expect(within(nav).getByRole("link", { name: "Лента" })).toHaveAttribute("aria-current", "page"); expect(within(nav).getByRole("link", { name: "Задачи" })).not.toHaveAttribute("aria-current"); }); @@ -70,16 +70,17 @@ describe("боковая панель", () => { expect(within(nav).getByRole("link", { name: "Доска связей" })).not.toHaveAttribute("aria-current"); expect(within(nav).getByRole("link", { name: "Задачи" })).not.toHaveAttribute("aria-current"); const main = within(screen.getByRole("main")); - expect(main.getByRole("link", { name: "Доска связей" })).toHaveAttribute("href", "/app/board"); - expect(main.getByRole("link", { name: "Задачи" })).toHaveAttribute("href", "/app/issues"); - expect(main.getByRole("link", { name: "Лента" })).toHaveAttribute("href", "/app/feed"); // M-2 + expect(main.getByRole("link", { name: "Доска связей" })).toHaveAttribute("href", "/app/jira/board"); + expect(main.getByRole("link", { name: "Задачи" })).toHaveAttribute("href", "/app/jira/issues"); + expect(main.getByRole("link", { name: "Лента" })).toHaveAttribute("href", "/app/jira/feed"); // M-2 + expect(main.getByRole("link", { name: "Обзор" })).toHaveAttribute("href", expect.stringMatching(/^\/app\/?$/)); expect(screen.getByRole("alert")).toHaveTextContent("Страница не найдена"); }); it("«Обновить из Jira» на /issues ставит пакет, недоступна, по done — «пакет #7 выполнен» и GET /issues повторно", async () => { let done = false; const h = hub({ "GET /batches/7": () => json(200, { id: 7, author: "viewer", status: done ? "done" : "running", remaining_seconds: 3, counts: {}, commands: [{ type: "refresh", status: done ? "done" : "running" }] }) }); - open("/app/issues?project=AWPCA"); + open("/app/jira/issues?project=AWPCA"); await screen.findByTestId("page-issues"); await waitFor(() => expect(h.count("GET", "/issues")).toBeGreaterThan(0)); const issuesBefore = h.count("GET", "/issues"); @@ -97,39 +98,60 @@ describe("боковая панель", () => { it("агент офлайн — «агент офлайн»; на связи — «агент на связи»", async () => { hub({ "GET /agents": () => json(200, { agents: [{ module: "jira", online: false, last_seen_at: "2026-10-14T09:30:00+00:00", warning: null, update_hint: null }] }) }); - open("/app/board?project=AWPCA"); + open("/app/jira/board?project=AWPCA"); expect(await within(bar()).findByText("агент офлайн")).toBeInTheDocument(); }); it("агент на связи", async () => { hub(); - open("/app/board?project=AWPCA"); + open("/app/jira/board?project=AWPCA"); expect(await within(bar()).findByText("агент на связи")).toBeInTheDocument(); }); it("«данные на ДД.ММ ЧЧ:ММ · все проекты» берутся из jira_synced_at проекта", async () => { hub(); - open("/app/issues?project=AWPCA"); + open("/app/jira/issues?project=AWPCA"); expect(await within(bar()).findByText(/^данные на \d\d\.\d\d \d\d:\d\d · все проекты$/)).toBeInTheDocument(); }); it("обновления ещё не было", async () => { hub({ "GET /projects": () => json(200, { projects: [{ ...A, jira_synced_at: null }] }) }); - open("/app/issues?project=AWPCA"); + open("/app/jira/issues?project=AWPCA"); expect(await within(bar()).findByText("обновления ещё не было")).toBeInTheDocument(); }); it("блок состояния не грузит GET /wiki/status; у проекта без корня вики строки «Вики» нет", async () => { const h = hub(); - open("/app/board?project=AWPCA"); + open("/app/jira/board?project=AWPCA"); await within(bar()).findByRole("button", { name: /^Обновить из Jira/ }); expect(within(bar()).queryByRole("button", { name: /^Обновить вики/ })).toBeNull(); expect(h.count("GET", "/wiki/status")).toBe(0); }); + it("группы по модулям: «Обзор» первым, затем заголовки «Jira» и «Вики» со своими разделами", async () => { + hub(); + open("/app/jira/board?project=AWPCA"); + const nav = within(bar()).getByRole("navigation", { name: "Разделы" }); + const links = (await within(nav).findAllByRole("link")).map((l) => l.textContent); + expect(links).toEqual(["Обзор", "Доска связей", "Задачи", "Лента", "Вики"]); + const jira = within(nav).getByRole("group", { name: "Jira" }); + expect(within(jira).getAllByRole("link").map((l) => l.textContent)).toEqual(["Доска связей", "Задачи", "Лента"]); + expect(within(nav).getByRole("group", { name: "Вики" })).toBeInTheDocument(); + await waitFor(() => expect(within(nav).getByRole("link", { name: "Обзор" })).toHaveAttribute("href", expect.stringMatching(/^\/app\/?\?project=AWPCA$/))); + }); + + it("свёрнутая: метка проекта целиком в title (M-4), у групп скрытые названия", async () => { + localStorage.setItem("sdlc-hub-sidebar", "collapsed"); + hub({ "GET /projects": () => json(200, { projects: [{ ...A, label: "STOCKS_DRRK_LONG" }] }) }); + open("/app/jira/board?project=STOCKS_DRRK_LONG"); + const tag = await within(bar()).findByRole("button", { name: "Проект STOCKS_DRRK_LONG — развернуть панель" }); + expect(tag).toHaveAttribute("title", "Проект STOCKS_DRRK_LONG"); + expect(within(bar()).getByRole("group", { name: "Jira" })).toBeInTheDocument(); + }); + it("меню «Ещё» → «Сменить токен» показывает экран входа", async () => { hub(); - open("/app/board?project=AWPCA"); + open("/app/jira/board?project=AWPCA"); await userEvent.click(await screen.findByRole("button", { name: "Ещё" })); await userEvent.click(screen.getByRole("button", { name: "Сменить токен" })); expect(getAuth().token).toBe(""); @@ -138,13 +160,13 @@ describe("боковая панель", () => { it("переключатель темы — в панели", async () => { hub(); - open("/app/board?project=AWPCA"); + open("/app/jira/board?project=AWPCA"); expect(await within(bar()).findByRole("switch", { name: "Тёмная тема" })).toBeInTheDocument(); }); it("доска не рисует свою кнопку «Обновить из Jira»: в приложении она одна", async () => { hub(); - open("/app/board?project=AWPCA"); + open("/app/jira/board?project=AWPCA"); await screen.findByTestId("page-board"); await screen.findByText(/задач из/); expect(screen.getAllByRole("button", { name: /^Обновить из Jira/ })).toHaveLength(1); @@ -152,21 +174,21 @@ describe("боковая панель", () => { it("разделы — в навигации «Разделы» боковой панели", async () => { hub(); - open("/app/board?project=AWPCA"); + open("/app/jira/board?project=AWPCA"); const nav = within(bar()).getByRole("navigation", { name: "Разделы" }); expect(await within(nav).findByRole("link", { name: "Доска связей" })).toHaveAttribute("aria-current", "page"); }); it("«Свернуть панель» сворачивает и запоминает в sdlc-hub-sidebar; после перезапуска свёрнута, разделы доступны", async () => { hub(); - open("/app/board?project=AWPCA"); + open("/app/jira/board?project=AWPCA"); const btn = await within(bar()).findByRole("button", { name: "Свернуть панель" }); expect(btn).toHaveAttribute("aria-expanded", "true"); await userEvent.click(btn); expect(localStorage.getItem("sdlc-hub-sidebar")).toBe("collapsed"); expect(bar()).toHaveClass("is-collapsed"); cleanup(); - open("/app/board?project=AWPCA"); + open("/app/jira/board?project=AWPCA"); expect(bar()).toHaveClass("is-collapsed"); expect(within(bar()).getByRole("button", { name: "Развернуть панель" })).toHaveAttribute("aria-expanded", "false"); expect(within(bar()).getByRole("link", { name: "Задачи" })).toHaveAttribute("title", "Задачи"); @@ -178,7 +200,7 @@ describe("боковая панель", () => { it("свёрнутая: «Обновить из Jira» значком ставит пакет, событие — в role=status и подсказке", async () => { localStorage.setItem("sdlc-hub-sidebar", "collapsed"); const h = hub(); - open("/app/issues?project=AWPCA"); + open("/app/jira/issues?project=AWPCA"); const btn = await within(bar()).findByRole("button", { name: /^Обновить из Jira/ }); await userEvent.click(btn); await waitFor(() => expect(btn).toBeDisabled()); @@ -190,7 +212,7 @@ describe("боковая панель", () => { it("свёрнутая: метка проекта видна, щелчок по ней разворачивает панель", async () => { localStorage.setItem("sdlc-hub-sidebar", "collapsed"); hub(); - open("/app/board?project=AWPCA"); + open("/app/jira/board?project=AWPCA"); const tag = await within(bar()).findByRole("button", { name: "Проект AWPCA — развернуть панель" }); await userEvent.click(tag); expect(bar()).not.toHaveClass("is-collapsed"); @@ -204,24 +226,25 @@ describe("боковая панель на узком экране", () => { }); afterEach(() => { vi.unstubAllGlobals(); }); - it("сверху строка с «Меню», названием и «Обновить из Jira»; панели нет", async () => { + it("сверху строка с «Меню», меткой проекта и сводной точкой; кнопки обновления и панели нет", async () => { hub(); - open("/app/board?project=AWPCA"); + open("/app/jira/board?project=AWPCA"); const top = await screen.findByRole("banner", { name: "Строка приложения" }); expect(within(top).getByRole("button", { name: "Меню" })).toHaveAttribute("aria-expanded", "false"); - expect(within(top).getByText("SDLC Hub")).toBeInTheDocument(); - expect(await within(top).findByRole("button", { name: /^Обновить из Jira/ })).toBeInTheDocument(); + expect(await within(top).findByText("AWPCA")).toBeInTheDocument(); + expect(await within(top).findByRole("img", { name: /агент на связи/ })).toBeInTheDocument(); + expect(within(top).queryByRole("button", { name: /^Обновить/ })).toBeNull(); expect(screen.queryByRole("complementary", { name: "Панель приложения" })).toBeNull(); }); it("«Меню» открывает панель с фокусом на первом разделе; Escape закрывает и возвращает фокус", async () => { hub(); - open("/app/board?project=AWPCA"); + open("/app/jira/board?project=AWPCA"); const menu = await screen.findByRole("button", { name: "Меню" }); await userEvent.click(menu); expect(menu).toHaveAttribute("aria-expanded", "true"); const nav = within(bar()).getByRole("navigation", { name: "Разделы" }); - expect(within(nav).getByRole("link", { name: "Доска связей" })).toHaveFocus(); + expect(within(nav).getByRole("link", { name: "Обзор" })).toHaveFocus(); expect(within(bar()).queryByRole("button", { name: "Свернуть панель" })).toBeNull(); await userEvent.keyboard("{Escape}"); expect(screen.queryByRole("complementary", { name: "Панель приложения" })).toBeNull(); @@ -230,16 +253,16 @@ describe("боковая панель на узком экране", () => { it("выбор раздела закрывает панель и открывает раздел", async () => { hub(); - const router = open("/app/board?project=AWPCA"); + const router = open("/app/jira/board?project=AWPCA"); await userEvent.click(await screen.findByRole("button", { name: "Меню" })); await userEvent.click(within(bar()).getByRole("link", { name: "Задачи" })); - expect(router.state.location.pathname).toBe("/app/issues"); + expect(router.state.location.pathname).toBe("/app/jira/issues"); expect(screen.queryByRole("complementary", { name: "Панель приложения" })).toBeNull(); }); it("щелчок по подложке и «Закрыть меню» закрывают панель", async () => { hub(); - open("/app/board?project=AWPCA"); + open("/app/jira/board?project=AWPCA"); await userEvent.click(await screen.findByRole("button", { name: "Меню" })); await userEvent.click(screen.getByTestId("sidebar-backdrop")); expect(screen.queryByRole("complementary", { name: "Панель приложения" })).toBeNull(); @@ -248,14 +271,35 @@ describe("боковая панель на узком экране", () => { expect(screen.queryByRole("complementary", { name: "Панель приложения" })).toBeNull(); }); - it("I-1: событие пакета видно под строкой, не только в подсказке", async () => { + it("I-1: событие пакета видно строкой под верхней строкой в постоянном живом регионе", async () => { hub(); - open("/app/board?project=AWPCA"); - const top = await screen.findByRole("banner", { name: "Строка приложения" }); - await userEvent.click(await within(top).findByRole("button", { name: /^Обновить из Jira/ })); - const strip = await screen.findByTestId("topbar-event"); + open("/app/jira/board?project=AWPCA"); + await screen.findByRole("banner", { name: "Строка приложения" }); + const strip = screen.getByTestId("topbar-events"); + expect(strip).toHaveAttribute("role", "status"); // T10.1: контейнер на месте до появления текста + expect(strip).toBeEmptyDOMElement(); + await userEvent.click(screen.getByRole("button", { name: "Меню" })); + await userEvent.click(await within(bar()).findByRole("button", { name: /^Обновить из Jira/ })); await waitFor(() => expect(strip).toHaveTextContent(/пакет #7 выполняется/)); expect(strip).not.toHaveClass("visually-hidden"); - expect(strip).toHaveAttribute("aria-hidden", "true"); // озвучивает role=status кнопки, второй раз не нужно + }); + + it("drawer is modal: фокус в панели, страница инертна; Escape во вложенном меню закрывает только меню (M-1)", async () => { + hub(); + open("/app/jira/board?project=AWPCA"); + const menu = await screen.findByRole("button", { name: "Меню" }); + await userEvent.click(menu); + expect(within(bar()).getByRole("link", { name: "Обзор" })).toHaveFocus(); + expect(screen.getByRole("main")).toHaveAttribute("inert"); + expect(screen.getByRole("banner", { name: "Строка приложения" })).toHaveAttribute("inert"); + await userEvent.click(within(bar()).getByRole("button", { name: "Ещё" })); + expect(within(bar()).getByRole("button", { name: "Сменить токен" })).toBeInTheDocument(); + await userEvent.keyboard("{Escape}"); + expect(within(bar()).queryByRole("button", { name: "Сменить токен" })).toBeNull(); + expect(bar()).toBeInTheDocument(); // панель осталась + await userEvent.keyboard("{Escape}"); + expect(screen.queryByRole("complementary", { name: "Панель приложения" })).toBeNull(); + await waitFor(() => expect(menu).toHaveFocus()); + expect(screen.getByRole("main")).not.toHaveAttribute("inert"); }); }); diff --git a/web/src/shell/Sidebar.tsx b/web/src/shell/Sidebar.tsx index 3e0440b..562ca66 100644 --- a/web/src/shell/Sidebar.tsx +++ b/web/src/shell/Sidebar.tsx @@ -1,9 +1,12 @@ import { useQueryClient } from "@tanstack/react-query"; import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; +import type { NavItem } from "../modules/types.ts"; import { Link } from "react-router"; +import type { Ref } from "react"; import { clearToken } from "../auth/token.ts"; -import { MODULES } from "../modules/index.ts"; -import { StatusBlock, TopbarNotice } from "./activity/StatusBlock.tsx"; +import { useActivitySummary } from "./activity/activity.ts"; +import { ModuleEventStrip, ModuleStatusBlock } from "./ModuleStatusBlock.tsx"; +import { GROUPS, OVERVIEW } from "./nav.tsx"; import { withProject } from "../projects/current.ts"; import { ProjectPicker } from "../projects/ProjectPicker.tsx"; import { IconButton, IconClose, IconMenu, IconSidebar, Menu, MenuItem, ThemeSwitch } from "../ui/index.ts"; @@ -11,8 +14,6 @@ import { readSidebar, saveSidebar } from "./sidebarPref.ts"; export type Section = string; -const SECTIONS = MODULES.flatMap((m) => m.nav); - // узкий экран (<640 px): панель не в потоке, а выезжает по кнопке «Меню» из закреплённой строки (design 4) const NARROW = "(max-width: 639px)"; const subscribe = (cb: () => void) => { @@ -31,21 +32,36 @@ export function Sidebar(p: Props) { return narrow ? : ; } +const SUMMARY_TEXT = { error: "ошибка", busy: "идёт обновление", online: "агент на связи", offline: "агент офлайн" } as const; + +// сводная точка состояния модулей: цвет не единственный признак — состояние в aria-label и title +function SummaryDot() { + const summary = useActivitySummary(); + const text = `Состояние модулей: ${SUMMARY_TEXT[summary]}`; + return ; +} + function NarrowShell(p: Props) { const [open, setOpen] = useState(false); const menuBtn = useRef(null); // объект стабилен, пока панель открыта: эффект фокуса и Escape в Panel не перезапускается на каждой отрисовке - const drawer = useMemo(() => ({ close: () => { setOpen(false); menuBtn.current?.focus(); }, navigated: () => setOpen(false) }), []); + const drawer = useMemo(() => ({ close: () => setOpen(false), navigated: () => setOpen(false) }), []); + // фокус возвращается на «Меню» после закрытия: пока панель открыта, строка инертна и фокус на неё не встал бы + const wasOpen = useRef(false); + useEffect(() => { + if (wasOpen.current && !open) menuBtn.current?.focus(); + wasOpen.current = open; + }, [open]); return ( <>
- SDLC Hub + {p.project && {p.project}} - +
- + {open && <>
@@ -54,21 +70,31 @@ function NarrowShell(p: Props) { ); } -// содержимое панели: название и свёртка (в выезжающей — «Закрыть меню»), проект, разделы, внизу состояние данных, тема, меню «Ещё» +// содержимое панели: название и свёртка (в выезжающей — «Закрыть меню»), проект, «Обзор» и разделы по модулям, внизу состояние +// модулей, тема, меню «Ещё»; прокручивается середина, низ с меню «Ещё» остаётся на месте (M-5 #73) function Panel({ section, project, drawer }: Props & { drawer?: { close(): void; navigated(): void } }) { const qc = useQueryClient(); const [stored, setCollapsed] = useState(readSidebar); const collapsed = !drawer && stored; const fold = (c: boolean) => { setCollapsed(c); saveSidebar(c); }; - const href = (to: Section) => (project ? to + withProject("", project) : to); + const href = (to: string) => (project ? to + withProject("", project) : to); const first = useRef(null); useEffect(() => { if (!drawer) return; first.current?.focus(); const esc = (e: KeyboardEvent) => { if (e.key === "Escape" && !e.defaultPrevented) drawer.close(); }; document.addEventListener("keydown", esc); - return () => document.removeEventListener("keydown", esc); + // выезжающая панель модальна: страница и строка приложения недоступны с клавиатуры и для чтения с экрана + const behind = [...document.querySelectorAll(".shell > :not(.sidebar):not(.sidebar-backdrop)")]; + behind.forEach((el) => el.setAttribute("inert", "")); + return () => { document.removeEventListener("keydown", esc); behind.forEach((el) => el.removeAttribute("inert")); }; }, [drawer]); + const link = (s: NavItem, ref?: Ref) => ( + + {s.icon}{s.label} + + ); return (