Merge pull request 'Веб: доработки «Бэклога» и «Спринта» по образцу Jira (#112)' (#114) from feat/112-agile-web-polish into main

This commit is contained in:
aep 2026-10-25 20:54:16 +03:00
commit e84f7b1b58
33 changed files with 2394 additions and 119 deletions

View File

@ -104,7 +104,12 @@
Экраны доски модуля jira — `web/src/modules/jira/agile/`: `model` (даты Jira, «осталось N дн.», склонение, состояние данных
`boardState`), `columns` (раскладка задач по колонкам доски, «Без колонки»), `AgileGate` (тип доски проекта, загрузка, ошибка, три
состояния данных), `BoardColumns` и страницы `BacklogPage` (`/jira/backlog`), `SprintPage` (`/jira/sprint`), `KanbanPage`
(`/jira/kanban`). Данные — один запрос `GET /agile?project=<метка>` (`modules/jira/agile.py`); страницы только смотрят.
(`/jira/kanban`). Фильтры и вид: `filters` (модель фильтров — исполнитель, «Эпик», «Скрыть выполненные», свёртка, скрытые колонки;
чистые функции), `agilePref` (запоминание вида в `localStorage` под ключом `sdlc-hub-agile`: `{метка: {backlog|sprint|kanban: вид}}`;
поиск и выбор спринтов не хранятся), `useAgileView` (хук состояния вида и поиска), `AgileToolbar` (панель фильтров), `EpicsPanel`
(панель «Эпики» бэклога и выбор эпика в узкой панели), `SprintPicker` (выбор спринтов), `ColumnsMenu` (скрытие колонок «Спринт» и
«Kanban»). «Бэклог» — фильтры, свёртка секций, панель эпиков; «Спринт» — фильтры, выбор спринтов (в адресе `?sprint=`, не в `localStorage`), скрытие колонок; «Kanban» — поиск/исполнитель и скрытие колонок.
Данные — один запрос `GET /agile?project=<метка>` (`modules/jira/agile.py`); страницы только смотрят (фильтры и вид в Jira не пишут).
Модуль `wiki` (`web/src/modules/wiki/index.tsx`): страница «Вики» (`/app/wiki`): дерево (`Tree`) с поиском (`Search`), страница
(`WikiPage`, `PageView` — `react-markdown` + `remark-gfm`, без `rehype-raw`; ссылки `page:` ведут на страницу вики, `attachment:`

View File

@ -0,0 +1,325 @@
# Доработки «Бэклога» и «Спринта» (#112) — план реализации
> **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:** фильтры, свёртка, панель эпиков, выбор спринтов, скрытие колонок, значки типов и доска во всю высоту окна на
страницах «Бэклог», «Спринт», «Kanban» веб-приложения sdlc-hub — только просмотр.
**Architecture:** только фронт (`web/src/modules/jira/agile/`), данные — прежний `GET /agile`. Фильтры — чистая
модель `filters.ts` + запоминание `agilePref.ts` + хук `useAgileView`; страницы и компоненты только рисуют. Общие
части — панель фильтров `AgileToolbar`, меню `ColumnsMenu`, `BoardColumns`.
**Tech Stack:** React 19 + TypeScript + Vite, TanStack Query, Vitest + Testing Library, Playwright; UI-кит
`web/src/ui`, токены дизайн-системы AURA.
**Ветка:** `feat/112-agile-web-polish` (worktree `.wt/agile-polish`).
**Spec:** `openspec/changes/agile-web-polish/` (требование «Спринты и бэклог» в `specs/web-app/spec.md`, решения —
`design.md`, рулинги дизайна R-1…R-13; рулинги контроллера — с R-14).
## Global Constraints
- Веб только смотрит: MUST NOT появляться действий, меняющих Jira; ни одна кнопка не ставит команд.
- Сервер, API, схема, агент, MCP — не меняются (никаких правок вне `web/` и `openspec/`/`docs/`, кроме сида
`web/scripts/seed-hub.py`).
- Ключ хранилища — ровно `sdlc-hub-agile`; JSON `{ "<метка>": { "backlog"|"sprint"|"kanban": AgileView } }`; поиск
(`q`) и выбор спринтов не хранятся; любая ошибка хранилища — значения по умолчанию, без исключения наружу.
- Тексты интерфейса — дословно из спеки: «Ключ или тема», «Исполнитель», «все», «Без исполнителя», «Скрыть
выполненные», «Сбросить фильтры», «Эпики», «Все задачи», «Без эпика», «Эпик», «Колонки», «Спринты», «показано K»,
«Нет задач», «Нет задач по фильтрам», «Нет активного спринта», «Без колонки».
- Стили — только токены дизайн-системы (как в `agile.css`), без новых цветов-литералов.
- Работа только в worktree `/Users/andrey.epifancev/projects/jira-bridge/sdlc-hub/.wt/agile-polish`: никаких записей
в основной checkout и в другие репозитории (O-6 #108); никаких команд, ждущих stdin (`cat > файл` без heredoc и т.п.).
- Зелёный = `make check` целиком (pytest + `npm run lint` + `npm test`) и `cd web && npm run e2e` целиком.
## Review Focus
1. **Задача в двух выбранных спринтах** (перенос из активного в будущий) — на доске одна карточка, не две → тест в Task 2
(`sprintIssues`) и Task 6.
2. **Сохранённое значение, которого больше нет** (исполнитель ушёл, эпик закрыт и пропал, колонку переименовали в
Jira) — значение остаётся видимым и снимаемым, страница не пустеет молча → тесты в Task 2 (`assigneeOptions`), Task 5,
Task 7.
3. **Испорченное хранилище** (`sdlc-hub-agile` = `"oops"`, `{"AWPCA": 5}`, поле `collapsed: "x"`, `setItem` бросает)
— страница открывается со значениями по умолчанию → тесты в Task 2.
4. **Проект сменён на открытой странице** — фильтры другого проекта не протекают (страница пересоздаётся по `key`
проекта, состояние читается по метке) → тест в Task 4.
5. **Длинная колонка и узкое окно** — 900 px: страница без горизонтальной прокрутки, доска прокручивается внутри,
полоса прокрутки доски в окне; 2560 px — колонки используют ширину окна → e2e в Task 8.
---
### Task 0: Окружение worktree (без коммита)
- [ ] `cd /Users/andrey.epifancev/projects/jira-bridge/sdlc-hub/.wt/agile-polish && uv sync && (cd web && npm ci)`
- [ ] `make check` — зелёный (база до правок; запомнить число тестов в ledger).
### Task 1: Формат SP и значки типов
**Files:**
- Modify: `web/src/modules/jira/agile/model.ts` (`fmtSp`, новое `typeKind`)
- Modify: `web/src/modules/jira/agile/TypeIcon.tsx`, `web/src/modules/jira/agile/agile.css`
- Modify: `web/src/ui/icons.tsx` (новые символы)
- Test: `web/src/modules/jira/agile/model.test.ts`, новый `web/src/modules/jira/agile/TypeIcon.test.tsx`
**Interfaces:**
- Produces: `fmtSp(n: number | null): string`; `type TypeKind = "epic" | "story" | "task" | "bug" | "subtask" | "other"`;
`typeKind(type: string | null): TypeKind`; `isEpic(i: { issue_type: string | null }): boolean` — всё в `model.ts`.
Значки: `IconBookmark`, `IconCircle`, `IconSquares`, `IconDot` в `web/src/ui/icons.tsx`.
- [ ] **Step 1: тесты.** `model.test.ts`: `fmtSp(null) === "—"`, `fmtSp(3) === "3"`, `fmtSp(0.1 + 0.2) === "0.3"`,
`fmtSp(2.5) === "2.5"`, `fmtSp(1/3) === "0.33"`, `fmtSp(0) === "0"`. `typeKind`: «Эпик», «epic», «Фича» → `epic`; «История», «Story» → `story`; «Задача», «Task» →
`task`; «Ошибка», «BUG» → `bug`; «Подзадача», «Sub-task», «Subtask» → `subtask`; `null`, `""`, «Риск» → `other`.
`TypeIcon.test.tsx`: для каждого вида — `role="img"` с `aria-label` = исходное имя типа (для `null` — «Задача» не
подставлять: `aria-label="Тип не указан"`), класс `agile-type--<kind>`.
- [ ] **Step 2:** `cd web && npx vitest run src/modules/jira/agile/model.test.ts src/modules/jira/agile/TypeIcon.test.tsx` — FAIL.
- [ ] **Step 3: реализация.** `fmtSp`: `Number(n.toFixed(2))` → `String` (M-6). `typeKind` — сравнение без учёта
регистра со списками имён из спеки. Символы в `icons.tsx` тем же `make(...)`: закладка, круг, два квадрата, точка;
эпик — существующий `IconCritical` (молния), задача — `IconCheck`. `TypeIcon`: `<span className="agile-type
agile-type--<kind>">` — квадрат 16 px с `border-radius:var(--radius-xs)`, фон — токен вида (эпик `--k-ui`, история
`--color-status-success`, задача и подзадача `--color-action-primary`, ошибка `--color-status-error`, прочее —
`--color-text-secondary`), символ 12 px цвета `--color-text-on-primary`. Контраст символа проверить в светлой и
тёмной теме; не хватает — рулинг с выбранным токеном, не литерал.
- [ ] **Step 4:** тот же vitest — PASS; `npm run lint` — PASS.
- [ ] **Step 5:** коммит `feat(web): значки типов по образцу Jira и формат SP (#112, M-6)`.
### Task 2: Модель фильтров и запоминание
**Files:**
- Create: `web/src/modules/jira/agile/filters.ts`, `web/src/modules/jira/agile/agilePref.ts`
- Test: `web/src/modules/jira/agile/filters.test.ts`, `web/src/modules/jira/agile/agilePref.test.ts`
**Interfaces:**
- Consumes: `isEpic`, `typeKind` (Task 1); типы `AgileIssue`, `AgileSprint`, `AgileResponse` (`../api/types.ts`).
- Produces (`filters.ts`):
- `type AgilePage = "backlog" | "sprint" | "kanban"`
- `interface AgileView { assignee: string; epic: string; hideDone: boolean; collapsed: string[]; hiddenColumns: string[]; epicsPanel: boolean }`
- `const DEFAULT_VIEW: AgileView` = `{ assignee: "", epic: "", hideDone: false, collapsed: [], hiddenColumns: [], epicsPanel: true }`
- `const NONE = "-"` — значение «Без исполнителя» / «Без эпика»
- `interface IssueFilter { q: string; assignee: string; epic: string; hideDone: boolean }`
- `matches(i: AgileIssue, f: IssueFilter): boolean` — И всех условий; `q` — подстрока без учёта регистра в `key` или `summary` (пробелы по краям `q` отбрасываются)
- `hasFilters(f: IssueFilter): boolean`
- `type Opt = { value: string; label: string }`
- `assigneeOptions(issues: AgileIssue[], current: string): Opt[]` — `["", "все"]`, `[NONE, "Без исполнителя"]`, затем уникальные исполнители по `localeCompare("ru")`; `current`, которого нет, добавляется в конец с подписью = значению
- `interface EpicEntry { key: string; title: string; count: number; done: boolean }`
- `epicEntries(d: AgileResponse): EpicEntry[]` — по спеке: строки-эпики из всех спринтов и бэклога по `rank` (без повторов), затем ключи из `epic_key`, строк которых нет, по ключу (`title` = ключ, `done` = false); `count` — задачи-не-эпики с этим `epic_key` в активных, будущих спринтах и бэклоге (без повторов по ключу)
- `byRank(a: AgileIssue, b: AgileIssue): number` — `rank` строкой, `null` в конце, затем по `key`
- `sprintIssues(sprints: AgileSprint[]): AgileIssue[]` — объединение без повторов по `key`, сортировка `byRank`
- `selectableSprints(sprints: AgileSprint[]): AgileSprint[]` — активные (в порядке входа), затем будущие по `start_date` (через `jiraDate`), без даты — в конце
- `parseSprintIds(param: string | null, sprints: AgileSprint[]): number[]` — id из `"101,102"`, только среди `selectableSprints`, порядок `selectableSprints`; пусто → id всех активных
- Produces (`agilePref.ts`): `const AGILE_KEY = "sdlc-hub-agile"`; `readView(label: string, page: AgilePage): AgileView`;
`saveView(label: string, page: AgilePage, v: AgileView): void` (не трогает другие страницы и проекты в JSON).
- [ ] **Step 1: тесты `filters.test.ts`** (фабрики `iss`/`sprint` — как в `BacklogPage.test.tsx`): `matches` по каждому
условию и их И (`q: " aurora-1 "` находит `AURORA-1` и `AURORA-10`; `q: "тема"` — по теме; `assignee: NONE` — только
`null`; `epic: NONE` — только `epic_key === null`; `hideDone`); `hasFilters` для пустого и каждого поля;
`assigneeOptions` — порядок, «Без исполнителя», ушедший `current` в конце; `epicEntries` на данных сценария «Фильтр
по эпику» спеки (`AURORA-10`: 3 в бэклоге + 2 в активном → `count` 5), эпик, на который ссылаются, но строки нет,
— в конце с `title` = ключ, задачи прошлых спринтов не считаются; `sprintIssues` — задача в двух спринтах одна,
`rank: null` в конце; `selectableSprints` — закрытые отброшены, будущий без даты последним; `parseSprintIds` —
`"102,999"` → `[102]`, `"abc"` → все активные, `null` → все активные, будущий `"103"` → `[103]`.
- [ ] **Step 2: тесты `agilePref.test.ts`:** нет ключа → `DEFAULT_VIEW`; запись `AWPCA/backlog` не меняет
`AWPCA/sprint` и `STOCKS/backlog`; мусор: `"oops"`, `"[1]"`, `{"AWPCA": 5}`, `{"AWPCA":{"backlog":{"collapsed":"x","hideDone":"yes"}}}`
→ по полю значения по умолчанию (`collapsed: []`, `hideDone: false`), годные поля сохраняются; `localStorage.setItem`
бросает (`vi.spyOn(Storage.prototype, "setItem")`) — `saveView` не бросает; `getItem` бросает — `readView` →
`DEFAULT_VIEW`.
- [ ] **Step 3:** `npx vitest run src/modules/jira/agile/filters.test.ts src/modules/jira/agile/agilePref.test.ts` — FAIL.
- [ ] **Step 4: реализация** по Interfaces; разбор хранилища — поле за полем с проверкой типа (строка, булево, массив строк).
- [ ] **Step 5:** тот же vitest — PASS; `npm run lint` — PASS.
- [ ] **Step 6:** коммит `feat(web): модель фильтров экранов доски и их запоминание (#112)`.
### Task 3: Хук состояния и панель фильтров
**Files:**
- Create: `web/src/modules/jira/agile/useAgileView.ts`, `web/src/modules/jira/agile/AgileToolbar.tsx`
- Modify: `web/src/modules/jira/agile/agile.css`
- Test: `web/src/modules/jira/agile/AgileToolbar.test.tsx`
**Interfaces:**
- Consumes: Task 2 целиком.
- Produces:
- `useAgileView(label: string, page: AgilePage): { view: AgileView; set(patch: Partial<AgileView>): void; q: string; setQ(v: string): void; filter: IssueFilter; reset(): void }`
— `set` пишет в хранилище (`saveView`) вне функции-обновителя (как T8.1 #69); `reset` очищает `q`, `assignee`,
`epic`, `hideDone` и сохраняет остальное.
- `AgileToolbar({ filter, setQ, set, onReset, assignees, showHideDone, children }: { filter: IssueFilter; setQ(v: string): void; set(patch: Partial<AgileView>): void; onReset(): void; assignees: Opt[]; showHideDone: boolean; children?: ReactNode })`
— `Panel` с `SearchInput label="Поиск по ключу или теме" placeholder="Ключ или тема"`, `Select aria-label="Исполнитель"`,
при `showHideDone` — `Checkbox label="Скрыть выполненные"`, слот `children` (кнопка «Эпики», выбор спринтов, меню
«Колонки»), кнопка «Сбросить фильтры» только при `hasFilters(filter)`. Без кнопок, ставящих команды.
- [ ] **Step 1: тесты `AgileToolbar.test.tsx`** (рендер с хуком в обёртке): ввод в поиск меняет `filter.q`; выбор
исполнителя сохраняется в `localStorage["sdlc-hub-agile"]` по метке и странице, `q` — нет; «Сбросить фильтры»
появляется после ввода и снимает всё; `showHideDone={false}` — флажка нет; повторный монтаж с той же меткой и
страницей — исполнитель и флажок восстановлены.
- [ ] **Step 2:** `npx vitest run src/modules/jira/agile/AgileToolbar.test.tsx` — FAIL.
- [ ] **Step 3: реализация**; стили `.agile-toolbar` (flex-wrap, gap `--space-3`, поиск растягивается) в `agile.css`.
- [ ] **Step 4:** тот же vitest — PASS; `npm run lint` — PASS.
- [ ] **Step 5:** коммит `feat(web): панель фильтров экранов доски (#112)`.
### Task 4: «Бэклог»: фильтры, свёртка секций, без строк эпиков
**Files:**
- Modify: `web/src/modules/jira/agile/BacklogPage.tsx`, `web/src/modules/jira/agile/agile.css`
- Test: `web/src/modules/jira/agile/BacklogPage.test.tsx`
**Interfaces:**
- Consumes: `useAgileView(label, "backlog")`, `AgileToolbar`, `matches`, `isEpic`, `assigneeOptions` (Tasks 1–3).
- Produces: `SprintSection`/секция «Бэклог» принимают `collapsed: boolean; onToggle(): void; shown: AgileIssue[]` —
Task 5 добавляет фильтр эпика через тот же `filter`.
- [ ] **Step 1: тесты** (дописать в `BacklogPage.test.tsx`, существующие оставить зелёными):
- заголовок секции — кнопка с `aria-expanded="true"`; щелчок — `false`, задач секции нет, итоги видны; после
повторного монтажа (`unmount` + `open`) секция свёрнута (сценарий «Свёрнутый активный спринт»); свернуть можно и
будущий, и «Бэклог»;
- эпик (`issue_type: "Фича"`) в бэклоге и в спринте — строкой не показан; «N задач» секции «Бэклог» его не считает;
- «Скрыть выполненные» на спринте из 10 задач (4 выполнены, `count: 10`): 6 строк, «10 задач» и «показано 6»;
прошлый спринт раскрыт — все его задачи (сценарий «Скрыть выполненные»);
- поиск «AURORA-50» — в «Бэклоге» одна строка, в секции спринта «Нет задач по фильтрам»;
- смена проекта (Review Focus 4): исполнитель выбран у `AWPCA`, открыт тот же адрес с другим scrum-проектом —
исполнитель «все».
- «Бэклог только смотрит»: на странице нет элементов с `draggable`, все кнопки — свёртка, панель фильтров или
раскрытие (перечислить по `getAllByRole("button")` и их именам).
- [ ] **Step 2:** `npx vitest run src/modules/jira/agile/BacklogPage.test.tsx` — новые FAIL.
- [ ] **Step 3: реализация.** Видимые задачи секции = `issues.filter(i => !isEpic(i) && matches(i, f))`; для прошлых
спринтов `hideDone` не применяется (`{...f, hideDone: false}`). «показано K» — когда `shown.length` меньше числа
задач-не-эпиков секции. Заголовок — `<button className="agile-section__toggle" aria-expanded aria-controls>` с
`IconChevronDown`/`IconChevronUp`; id секции в `collapsed` — `String(sprint.id)` и `"backlog"`.
- [ ] **Step 4:** тот же vitest — PASS; `npm run lint` — PASS.
- [ ] **Step 5:** коммит `feat(web): «Бэклог» — фильтры, свёртка секций, эпики не строками (#112)`.
### Task 5: Панель «Эпики» на «Бэклоге»
**Files:**
- Create: `web/src/modules/jira/agile/EpicsPanel.tsx`
- Modify: `web/src/modules/jira/agile/BacklogPage.tsx`, `web/src/modules/jira/agile/agile.css`
- Test: `web/src/modules/jira/agile/BacklogPage.test.tsx`
**Interfaces:**
- Consumes: `epicEntries`, `EpicEntry`, `NONE`, `useAgileView` (Task 2–3), страница из Task 4.
- Produces: `EpicsPanel({ entries, value, onChange, hideDone }: { entries: EpicEntry[]; value: string; onChange(v: string): void; hideDone: boolean })`
и `EpicSelect` с теми же пропсами (`Select aria-label="Эпик"`: «Все задачи», эпики, «Без эпика»).
- [ ] **Step 1: тесты:**
- сценарий «Фильтр по эпику» спеки дословно (3 + 5 в бэклоге, 2 в спринте; выбран `AURORA-10` → в спринте 2 и
«показано 2», в «Бэклоге» 3, строки эпика нет, у пункта в панели 5);
- пункт — кнопка с `aria-pressed`; повторный щелчок по выбранному — «Все задачи» (`aria-pressed="true"`);
- «Без эпика» — только задачи с `epic_key: null`;
- эпик, на который ссылаются, но строки нет, — пункт с ключом; фильтр по нему работает;
- «Скрыть выполненные»: выполненный эпик пропадает из панели, кроме выбранного (Review Focus 2);
- сохранённый эпик, которого нет в данных, остаётся выбранным пунктом (по ключу), секции — «Нет задач по фильтрам»;
- кнопка «Эпики» в панели фильтров (`aria-pressed`) скрывает и показывает панель, состояние переживает повторный монтаж;
- фильтр эпика применяется и к задачам прошлого спринта.
- [ ] **Step 2:** vitest файла — новые FAIL.
- [ ] **Step 3: реализация.** Раскладка `.agile-backlog`: сетка `240px minmax(0,1fr)` (без панели — одна колонка);
при `@container page (max-width: 899px)` панель скрыта, в панели фильтров показан `EpicSelect` (на широком —
скрыт); кнопка «Эпики» на узком скрыта. Пункт: тема (или ключ), ключ моноширинным, число.
- [ ] **Step 4:** vitest файла — PASS; `npm run lint` — PASS.
- [ ] **Step 5:** коммит `feat(web): панель «Эпики» на «Бэклоге» (#112)`.
### Task 6: «Спринт»: выбор спринтов и фильтры
**Files:**
- Create: `web/src/modules/jira/agile/SprintPicker.tsx`
- Modify: `web/src/modules/jira/agile/SprintPage.tsx`
- Test: `web/src/modules/jira/agile/SprintPage.test.tsx`
**Interfaces:**
- Consumes: `selectableSprints`, `parseSprintIds`, `sprintIssues`, `matches`, `useAgileView(label, "sprint")`, `AgileToolbar`.
- Produces: `SprintPicker({ sprints, selected, onChange }: { sprints: AgileSprint[]; selected: number[]; onChange(ids: number[]): void })`
— `Dropdown` с кнопкой «Спринты» (на кнопке — имя единственного выбранного или «Спринты: N»), внутри `Checkbox` по
спринту (имя, «активный»/«будущий»); снять последнюю отметку нельзя (`disabled`). `BoardColumns` получает
отфильтрованные задачи (Task 7 добавит `hidden`).
- [ ] **Step 1: тесты** (переписать «два активных: переключатель…», «один активный: переключателя нет», «устаревший
?sprint=», «мусорный ?sprint=abc» под новую спеку; остальные — оставить):
- по умолчанию выбраны все активные; два активных → два заголовка и задачи обоих;
- сценарий «Будущий спринт на доске»: отметить будущий 102 при активном 101 → адрес `?sprint=101,102` без новой
записи истории (`router.state.historyAction === "REPLACE"`), два заголовка, «осталось N дн.» только у 101;
- задача в обоих выбранных спринтах — одна карточка (Review Focus 1);
- `?sprint=999` и `?sprint=abc` → все активные; `?sprint=103` (будущий) → только он;
- нет активного, есть будущий → «Нет активного спринта», ссылка «Бэклог» и выбор спринтов доступен; выбор будущего
показывает доску;
- сценарий «Исполнитель на доске спринта»: выбран исполнитель → только его карточки, числа колонок — по видимым; на
«Бэклоге» (`open("/jira/backlog?…")`) исполнитель «все»;
- «Скрыть выполненные» убирает выполненные карточки.
- [ ] **Step 2:** vitest файла — новые FAIL.
- [ ] **Step 3: реализация** по Interfaces; заголовки — `SprintHead` на каждый выбранный, компактно (`agile-section__head`),
«осталось…»/«срок прошёл» — только у `state === "active"`. Прежний `Segmented` убрать.
- [ ] **Step 4:** vitest файла — PASS; `npm run lint` — PASS.
- [ ] **Step 5:** коммит `feat(web): «Спринт» — выбор активных и будущих спринтов, фильтры (#112)`.
### Task 7: Колонки доски: скрытие, одна высота, прокрутка; «Kanban»
**Files:**
- Create: `web/src/modules/jira/agile/ColumnsMenu.tsx`
- Modify: `web/src/modules/jira/agile/BoardColumns.tsx`, `SprintPage.tsx`, `KanbanPage.tsx`, `agile.css`
- Test: `web/src/modules/jira/agile/SprintPage.test.tsx`, `KanbanPage.test.tsx`, `columns.test.ts` при необходимости
**Interfaces:**
- Consumes: `ColumnView`/`columnsOf` (без изменений), `useAgileView(label, "sprint" | "kanban")`, `AgileToolbar`.
- Produces: `ColumnsMenu({ names, hidden, onChange }: { names: string[]; hidden: string[]; onChange(hidden: string[]): void })`
— `Dropdown` с кнопкой «Колонки» (+ `Badge` с числом скрытых, если > 0), отметки по именам; последняя видимая —
`disabled`; имя из `hidden`, которого нет в `names`, в меню не показывается и на `Badge` не считается.
`BoardColumns({ columns, lastCaption, hidden }: { columns: ColumnView[]; lastCaption?: string; hidden?: string[] })`.
- [ ] **Step 1: воспроизвести баг п. 10** (superpowers:systematic-debugging): тестовый хаб (`bash web/scripts/test-hub.sh`
в фоне + `npm run dev` или собранное приложение) с широкой доской из Task 8 Step 1 (если её ещё нет — временно в
своём сиде), Playwright-скрипт в scratchpad: окно 2560 × 1300, замерить у `.page`, `.agile-columns` ширину и
`getBoundingClientRect().bottom` против `innerHeight`. Записать причину в ledger. Ожидаемая по design #9 и R-12:
полоса прокрутки ниже окна и ширина страницы ограничена `max-width:1440px` у `.page`. Если причина другая — чинить
её, требование спеки то же.
- [ ] **Step 2: тесты:** сценарий «Скрытая колонка» (снять «Готово» → колонки нет, на «Колонки» — 1, после повторного
монтажа так же); последнюю видимую снять нельзя; заголовки колонок — `getAllByRole("heading", { level: 3 })` (T10.3);
«Kanban»: поиск и исполнитель фильтруют колонки и «Бэклог (N)», флажка «Скрыть выполненные» нет, меню «Колонки» есть;
скрытое имя колонки, которой больше нет, не ломает меню.
- [ ] **Step 3:** vitest файлов — новые FAIL.
- [ ] **Step 4: реализация.** Скрытие — фильтр `columns` по имени в `BoardColumns`. Раскладка (design #9, R-12):
страница доски (`.agile--wide`) без ограничения `max-width` у `.page` (`.page:has(> .agile--wide)` или класс от
страницы — выбрать и записать рулинг), доска занимает высоту окна под шапкой и панелью фильтров (`min-height:360px`;
на экране < 640 px учесть закреплённую строку «Меню»), `.agile-columns{align-items:stretch}`, у колонки
прокручивается `.agile-col__list` (`overflow-y:auto; min-height:0`). Горизонтальная прокрутка — у `.agile-columns`.
- [ ] **Step 5:** vitest файлов — PASS; `npm run lint` — PASS; скрипт из Step 1 — полоса прокрутки в окне, последняя
колонка достижима, колонки одной высоты.
- [ ] **Step 6:** коммит `fix(web): колонки доски — одна высота, прокрутка в окне, скрытие колонок (#112, T10.3)`.
### Task 8: e2e, сид и попутные тесты hub#111
**Files:**
- Modify: `web/scripts/seed-hub.py`, `web/e2e/agile.spec.ts`
- Test: `web/src/modules/jira/agile/AgileGate.test.tsx` (новый), `web/src/shell/nav.test.ts` или `KanbanPage.test.tsx`
**Interfaces:**
- Consumes: всё выше; сид — прежний формат `boards` в `mirror.apply_state`.
- [ ] **Step 1: сид.** Проект `WIDE_E2E` (scrum-доска 6100): 11 колонок по своим статусам + задачи со статусом вне
колонок («Без колонки»), активный спринт, 40 задач в одной колонке, по 1–3 в остальных; эпики для AWPCA уже есть в
фикстуре (`AURORA-9100` «Фича», ссылки на отсутствующие `AURORA-1359`, `AURORA-9999`). Фиксированный `jira_updated`
у задач канбана вместо «сегодня 12:00», если от него не зависит «за 14 дней» (T12.2; если зависит — дата
`today - 1 day` и комментарий). Проверить, что прочие e2e (`projects.spec.ts`, `overview.spec.ts`) не считают проекты
жёстко; если считают — поправить ожидание.
- [ ] **Step 2: e2e** (`agile.spec.ts`):
- «Широкая доска» по спеке: окно 2560 × 1300, `WIDE_E2E` — высоты всех `.agile-col` равны (±1 px), нижний край
`.agile-columns` ≤ `innerHeight`, `scrollWidth > clientWidth` у доски, после `scrollLeft = scrollWidth` колонка
«Без колонки» целиком в окне, `document.documentElement.scrollWidth ≤ clientWidth`, ширина `.agile-columns` больше
1440 px;
- 900 px — прежний тест плюс нижний край доски ≤ `innerHeight`;
- «Бэклог» AWPCA: панель «Эпики» с `AURORA-9100`, выбор — строки только его задач; свёртка секции переживает
перезагрузку;
- «Спринт» AWPCA: отметить «AWPCA_Спринт 3» → адрес `sprint=102,103`; скрыть «Готово» → после перезагрузки скрыта;
- T12.3/T12.4: в существующих тестах файла — локаторы по роли и точному тексту вместо нестрогих `getByText` и
`.last()`/`.first()` по порядку DOM.
- [ ] **Step 3: T9.4** — vitest `AgileGate.test.tsx`: «Загрузка…» пока `/agile` висит; ошибка `/agile` → текст ошибки и
«Повторить», щелчок повторяет запрос; меню kanban-проекта: «Kanban» есть, «Бэклог»/«Спринт» нет.
- [ ] **Step 4:** `cd web && npm run e2e` — весь набор PASS; `make check` (из корня worktree) — PASS.
- [ ] **Step 5:** коммит `test(web): e2e широкой доски и фильтров, сид WIDE_E2E, тесты AgileGate (#112, T9.4, T12.2–T12.4)`.
### Task 9: Документы
**Files:**
- Modify: `openspec/changes/agile-web-polish/design.md` (рулинги контроллера R-13… в «Decisions made without discussion»),
`CLAUDE.md` / `ARCHITECTURE.md` — только если упоминают состав `agile/` или ключи `localStorage` (проверить grep).
- [ ] `grep -n "agile\|sdlc-hub-legend" CLAUDE.md ARCHITECTURE.md` — дописать `sdlc-hub-agile` и новые файлы, где
перечислены соседние; `openspec validate agile-web-polish --strict` — valid.
- [ ] коммит `docs: доработки экранов доски — рулинги и карта (#112)`.
**После плана (сессия, не SDD):** перенести новые значки и стиль `agile-type` в kit скилла `aura-design`
(вне worktree — только руками сессии), PR в Gitea, выкатка `make web` + kickstart — за владельцем.

View File

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

View File

@ -0,0 +1,166 @@
## Context
После выкатки #108 (25.10.2026) владелец посмотрел «Бэклог», «Спринт» и «Kanban» на живой доске AWPCA (4216, три
спринта) и записал 10 замечаний в sdlc-hub#112. Владелец попросил не проводить brainstorming-вопросы и писать дизайн
по тексту issue, поэтому всё, что в issue не сказано прямо, — решения агента (раздел «Decisions made without
discussion»).
Текущее состояние (`web/src/modules/jira/agile/`): `BacklogPage` рисует секции `Panel` без свёртки, эпики идут строками
наравне с задачами; `SprintPage` показывает один активный спринт, переключатель `Segmented` — только между активными;
`BoardColumns` — flex-ряд с `align-items:flex-start` и `overflow-x:auto`; `TypeIcon` знает три вида (эпик —
`IconBoard`, ошибка — `IconCritical` (молния), прочее — `IconCheck`). Фильтров нет. Все нужные данные уже приходят в
`GET /agile` (`AgileIssue`: `issue_type`, `assignee`, `epic_key`, `done`, `rank`, `status`) — сервер не меняется.
## Goals / Non-Goals
Цели — 10 пунктов #112 и попутно веб-маркеры из hub#111 (M-6, T10.3, T9.4, T12.2–T12.4). Не цели — правки Jira из
веба, изменения API/схемы/агента/MCP, настройки доски AWPCA в Jira, серверные пункты hub#111.
## Decisions
1. **Фильтры — на клиенте, чистой моделью.** Новый модуль `agile/filters.ts`: тип состояния страницы
(`q`, `assignee`, `epic`, `hideDone`, `collapsed`, `hiddenColumns`, `epicsPanel`), функция «видна ли задача»
(поиск по `key` и `summary` без учёта регистра; `assignee`: `""` — все, `"-"` — без исполнителя; `epic`: `""` — все,
`"-"` — без эпика, иначе `epic_key === epic`; `hideDone`), списки вариантов исполнителей и эпиков. Страницы и
компоненты только рисуют. Модель покрыта unit-тестами, страницы — тестами поведения.
2. **Запоминание — один ключ `sdlc-hub-agile`.** JSON `{ "<метка>": { "backlog": {...}, "sprint": {...},
"kanban": {...} } }`; хранится всё, кроме `q` и выбора спринтов. Чтение и запись — в `try/catch` (закрытое
хранилище, мусор, чужая форма — значения по умолчанию), как `sidebarPref`/`legendPref`. Миграция `jira-hub-` не нужна
— ключ новый. Свёрнутые секции — по id спринта и `"backlog"`; id закрытых/удалённых спринтов при чтении не мешают
(просто не совпадают). Скрытые колонки — по имени колонки (`"Без колонки"` — тоже имя).
3. **Панель фильтров** — общий компонент `AgileToolbar` для трёх страниц, набор элементов — пропсами. Используются
готовые `SearchInput`, `Select`, `Checkbox`/`Button aria-pressed`, `Dropdown`/`Menu` из `web/src/ui`; новые элементы
UI-кита не вводятся, кроме значков (п. 7). Устаревшее значение исполнителя/эпика остаётся в списке (как
`withCurrent` у списка задач), чтобы пустой результат объяснялся видимым фильтром.
4. **Свёртка секций «Бэклога».** Заголовок секции — `<button aria-expanded aria-controls>` с шевроном
(`IconChevronDown`/`Up`) и прежним содержимым заголовка; свёрнутая секция показывает только заголовок с итогами.
«Прошлые спринты» остаются `<details>` (не запоминаются — как сейчас).
5. **Эпики на «Бэклоге».** Эпик — задача, у которой `TypeIcon` определяет вид «эпик» (одна функция `typeKind` для
значка и фильтра). Строки эпиков убираются из всех секций «Бэклога» (и из счёта «N задач» секции «Бэклог», который
считается на клиенте). Панель — колонка слева шириной ~240 px в сетке страницы; при контейнере `page` уже 900 px —
`Select «Эпик»` в панели фильтров. Число у эпика — задачи с этим `epic_key` в активных, будущих спринтах и «Бэклоге»
без учёта остальных фильтров. На «Спринте» и «Kanban» эпики не трогаются (в issue речь только о бэклоге).
6. **Итоги секций — факты спринта.** Заголовок спринта показывает серверные `count`/`points`/`unestimated`; фильтры их
не меняют, добавляется «показано K», когда видно меньше задач секции. Так ретро-цифры спринта не «плавают» от фильтра.
Числа в заголовках колонок доски — по видимым карточкам.
7. **Значки типов по образцу Jira.** `typeKind(issue_type)` → `epic | story | task | bug | subtask | other`; значок —
скруглённый квадрат 16 px цвета вида с белым символом (эпик — молния, история — закладка, задача — галочка, ошибка
— круг, подзадача — два квадрата, прочее — нейтральный серый квадрат с точкой). Новые символы — в
`web/src/ui/icons.tsx` тем же `make(...)`; цвета — токенами дизайн-системы (эпик — `--k-ui`, история —
`--color-status-success`, задача — `--color-action-primary`, ошибка — `--color-status-error`, подзадача и прочее —
нейтральные), контраст белого символа проверить в обеих темах. При завершении — перенести значки в kit скилла
`aura-design`.
8. **Выбор спринтов на «Спринте».** `Dropdown` с отметками (активные, затем будущие по `start_date`, без даты — в
конце), адрес `?sprint=<id>[,<id>…]` с `replace` (без записи истории). Задачи доски — объединение задач выбранных
спринтов без повторов по ключу, порядок — по `rank` (строка LexoRank сравнивается как строка; `null` — в конце, затем
по ключу). Заголовков столько, сколько выбрано спринтов, компактной строкой каждый; «осталось N дн.» — только у
активного. Прежний одиночный `?sprint=<id>` остаётся рабочим адресом.
9. **Колонки одной высоты и прокрутка (пп. 8 и 10).** Гипотеза по багу (подтвердить воспроизведением в начале
задачи, superpowers:systematic-debugging): горизонтальная полоса `.agile-columns` есть, но лежит под самой длинной
колонкой, далеко ниже окна (и скрыта macOS до прокрутки), поэтому правые колонки на 2560 px выглядят обрезанными.
Исправление: страницы доски растягиваются на высоту основной области оболочки (`flex:1; min-height:0` по цепочке,
доска `min-height:360px`), `.agile-columns` — `align-items:stretch` и своя высота, у колонки прокручивается список
(`overflow-y:auto`). Тогда полоса горизонтальной прокрутки всегда у нижнего края окна, колонки одной высоты. Если
воспроизведение покажет другую причину — чинится причина, требование спеки то же.
10. **Скрытие колонок** — меню «Колонки» с отметками на «Спринте» и «Kanban»; последнюю видимую снять нельзя; число
скрытых — `Badge` на кнопке. Скрытие — только вид, `columnsOf` не меняется, фильтруется результат.
11. **Попутные маркеры hub#111.** M-6 — `fmtSp` округляет до двух знаков и убирает хвостовые нули (веб-сторона;
MCP `:g` не трогаем, расхождение для дробных до сотых исчезает). T10.3 — `h3` в заголовках колонок. T9.4 — тесты
загрузки и ошибки `AgileGate` и пункта «Kanban» kanban-проекта в меню. T12.2–T12.4 — e2e доски: фиксированный
`jira_updated` в сиде, строгие локаторы по роли и тексту, без зависимости от порядка DOM.
12. **Тестовые данные.** Сид e2e (`web/scripts/test-hub.sh` и фикстуры агента) дополняется так, чтобы были: эпик с
задачами в спринте и бэклоге, задачи без эпика и без исполнителя, будущий спринт с задачами, доска с числом колонок
и карточек, достаточным для широкой доски из сценария «Широкая доска».
## Risks / Trade-offs
- **Высота доски от оболочки.** Растяжение по высоте зависит от цепочки flex в `shell.css`/`Layout.tsx`; нельзя
сломать прокрутку других страниц — e2e «страница без горизонтальной прокрутки» и сценарии оболочки остаются зелёными.
- **Объединение спринтов.** Задача может числиться и в активном, и в будущем спринте (перенос) — без дедупликации
карточка задвоится; дедупликация по ключу — в п. 8.
- **Распознавание эпика по имени типа.** Тип зависит от локали Jira; список имён — один на значок и фильтр, расширяется
в одном месте.
## Decisions made without discussion
Все — 25.10.2026, владелец попросил писать дизайн по issue без вопросов. Нумерация рулингов R-n — с R-1 в пределах
этой фичи.
- R-1 (design #2): фильтры и свёртка запоминаются отдельно по странице (`backlog`/`sprint`/`kanban`) и проекту, поиск
и выбор спринтов — не запоминаются. Обоснование: как в Jira — поиск разовый, выбор спринтов живёт в адресе.
Цена ошибки — сделать исполнителя общим для страниц или запоминать поиск: правка одного модуля `filters.ts`.
- R-2 (design #5): эпики убираются строками только на «Бэклоге»; на «Спринте» и «Kanban» остаются карточками.
Обоснование: в issue речь о бэклоге. Цена ошибки — тот же фильтр на доске, одна строка.
- R-3 (design #5): фильтр по эпику действует и на прошлые спринты; число у эпика — без учёта других фильтров и без
прошлых спринтов. Цена ошибки — правка подсчёта в модели.
- R-4 (design #6): итоги секций спринта не зависят от фильтров, добавляется «показано K»; числа колонок доски — по
видимым. Цена ошибки — пересчитывать итоги на клиенте.
- R-5 (spec): «Скрыть выполненные» не действует на прошлые спринты и на «Kanban» (у kanban последняя колонка — уже
«за 14 дней»). Цена ошибки — включить флагом.
- R-6 (design #8): выбор спринтов — несколько сразу (по умолчанию все активные), в адресе. Формулировка issue
(«выбор отображаемых спринтов: и активные, и будущие») допускает и одиночный выбор; множественный — надмножество.
Цена ошибки — заменить список отметок одиночным выбором.
- R-7 (spec): поиск и исполнитель — на всех трёх страницах, включая «Kanban», хотя в issue названы «Бэклог» и доска
спринта: общий компонент, единообразие. Цена ошибки — скрыть два поля на «Kanban».
- R-8 (design #7): набор видов значков и их цвета (Jira: эпик фиолетовый с молнией, история зелёная с закладкой,
задача синяя с галочкой, ошибка красная с кругом, подзадача). Цена ошибки — перерисовать значки.
- R-9 (design #9): причина бага прокрутки — гипотеза, подлежит проверке воспроизведением; починка — через растяжение
доски на высоту окна, совмещённую с п. 8. Цена ошибки — другая починка того же требования.
- R-10 (proposal): из hub#111 взяты только веб-маркеры M-6 (веб-сторона), T10.3, T9.4, T12.2–T12.4; hub#111 после
merge остаётся открытой с комментарием о закрытых маркерах. Цена ошибки — взять/отложить другие пункты.
- R-11 (spec): узкий экран — панель эпиков превращается в выбор при контейнере уже 900 px. Цена ошибки — подобрать
порог.
- R-12 (plan, Task 7): на страницах доски («Спринт», «Kanban») страница не ограничена `max-width: 1440px` у `.page` —
на 2560 px колонки используют ширину окна (комментарий в `agile.css` уже называл «Спринт» страницей во всю ширину, а
ограничение оставалось). Обоснование: замечание владельца о 2560 px. Цена ошибки — вернуть ограничение одной строкой.
- R-13 (plan, Task 1): значок задачи без типа подписан «Тип не указан» (сейчас — «Задача»), вид — нейтральный.
Цена ошибки — вернуть прежнюю подпись.
- R-14 (controller, 25.10.2026, design #34): сид `WIDE_E2E` (Task 8 Step 1) делается в Task 7 Step 1 — как
воспроизводящая широкая доска для баги прокрутки; Task 8 Step 1 остаётся проверкой прочих e2e и T12.2. Обоснование:
без сида воспроизведение невозможно, порядок плана противоречив. Цена ошибки — сид приезжает на коммит раньше.
- R-15 (controller, 25.10.2026, design #35): рулинги контроллера нумеруются с R-14 (R-13 занят дизайном; в плане T9
написано «R-13…»). Цена ошибки — нулевая.
- R-16 (controller, 25.10.2026, design #36, из ревью T4.2/T4.3/T4.4): (а) у прошлого спринта при активных фильтрах
в сводке добавляется « · показано K» (спека описывает «показано K» только для активных и будущих; строка
сценария «Прошлый спринт» без фильтров не меняется); (б) список исполнителей строится по всем задачам-не-эпикам
страницы, включая прошлые спринты (исполнитель фильтрует и их); (в) «N задач» секции спринта — серверное `count`
(включает эпики, R-4), а строки и «показано K» считаются по задачам-не-эпикам, поэтому спринт с эпиком может
показать «N задач» больше числа строк. Цена ошибки — правка двух выражений в BacklogPage.tsx.
- R-17 (controller, 25.10.2026, design #37, Task 7 Step 1): гипотеза R-9/R-12 о причине бага прокрутки не подтвердилась
воспроизведением (systematic-debugging). `max-width:1440px` у `.page` страниц доски уже снят (`.page--board`), доска на
2560 px шире 1440 px. Настоящая причина: у `.agile-columns` не было ограничения по высоте — высота доски равна самой
длинной колонке (3326 px), горизонтальная полоса прокрутки лежала на y=3616 при окне 1300 px, колонки были разной
высоты (`align-items:flex-start`), список колонки не прокручивался. Починка: `.agile--wide` высотой окна, колонки
`stretch`, прокрутка у `.agile-col__list`, горизонтальная — у `.agile-columns`. R-12 в части снятия `max-width`
теряет силу (править нечего). Цена ошибки — нулевая: снятие ограничения не делалось.
- R-18 (controller, 25.10.2026, design #38, из отчёта Task 7): раскрытый «Бэклог (N)» на «Kanban» ограничен по высоте
(40dvh) с прокруткой; меню «Колонки» включает «Без колонки», если такая есть. Цена ошибки — правка CSS/списка меню.
- R-19 (controller, 25.10.2026, design #39, из I-1 финального ревью): цвета значков типов — эпик `--k-ui`, история
`--color-status-success`, задача `--k-dev`, подзадача `--k-analytics`, ошибка `--color-status-error`, прочее
`--color-text-secondary`; символ — `--color-text-on-primary` (в тёмной теме тёмный, контраст ≥ 3:1 во всех парах,
рассчитан по tokens.css). Прежний выбор плана (задача на `--color-action-primary`) давал один зелёный с историей,
что противоречило «у каждого вида свои цвет и символ»; фраза спеки «белый символ» заменена на «символ цвета
`--color-text-on-primary`». Цена ошибки — пересадить два токена в agile.css.
- R-20 (controller, 25.10.2026, design #40, из I-3): на окне уже 640 px минимальная высота доски 240 px (а не 360):
при 360 px полоса прокрутки доски уходит ниже окна (810 и 838 против 800 на 390×800, замеры Task 8 и волны
финального ревью), а требование «полоса прокрутки в пределах окна» важнее. Спека поправлена (240 px на узком окне).
Цена ошибки — вернуть 360 px и принять полосу ниже окна.
- R-21 (controller, 25.10.2026, design #41, из I-3): (а) последняя отмеченная позиция в выборе спринтов недоступна
(нельзя снять); (б) скрытая колонка, которой нет на доске, в меню «Колонки» не показывается и на кнопке не
считается, а если скрыты все колонки — не скрыто ничего (доска не пустеет молча; список имён меню строится по
нефильтрованным задачам); (в) подпись поля поиска для экранного диктора — «Поиск по ключу или теме», видимая —
«Ключ или тема»; (г) подпись «Исполнитель» — inline слева от выбора (подпись сверху сдвигала выбор относительно
поиска). Review Focus 2 плана («значение остаётся видимым и снимаемым») для колонок трактуется так: значение,
которого нет в данных, не нужно показывать, потому что оно ни на что не влияет. Цена ошибки — показывать
«призрачные» колонки в меню.

View File

@ -0,0 +1,38 @@
## Why
25.10.2026, после выкатки #108, владелец посмотрел страницы «Бэклог», «Спринт» и «Kanban» на живых данных AWPCA и
собрал 10 замечаний (sdlc-hub#112): страницам не хватает привычных по Jira средств навигации — свернуть спринт,
отфильтровать по эпику, исполнителю, тексту, убрать выполненное, выбрать спринты и колонки доски; значки типов не
читаются; колонки разной высоты дают визуальный шум, а на широком экране правые колонки обрезаны без прокрутки.
Веб по-прежнему только для просмотра: всё ниже — вид и фильтры, Jira не меняется.
## What Changes
- **«Бэклог»:** сворачивается любая секция (активные и будущие спринты, «Бэклог»); эпики — панелью слева, выбор
эпика фильтрует все секции, сами эпики строками списка больше не идут; быстрый поиск по ключу и теме; «Скрыть
выполненные»; фильтр по исполнителю.
- **«Спринт»:** выбор отображаемых спринтов — любые активные и будущие, несколько сразу (`?sprint=<id>,<id>`); тот же
поиск, исполнитель, «Скрыть выполненные»; скрытие колонок; колонки одной высоты во всю доску, задачи прокручиваются
внутри колонки, горизонтальная прокрутка доски всегда в пределах окна.
- **«Kanban»:** общие с «Спринтом» колонки — та же высота, прокрутка и скрытие колонок; поиск и исполнитель.
- **Значки типов** — по образцу Jira: эпик, история, задача, ошибка, подзадача, прочее.
- **Запоминание:** свёрнутые секции, эпик, исполнитель, «Скрыть выполненные», скрытые колонки, видимость панели
эпиков — в `localStorage` по проекту и странице (`sdlc-hub-agile`). Поиск и выбор спринтов не запоминаются.
- **Попутно из hub#111:** M-6 (формат SP в вебе), T10.3 (заголовки колонок `h3`), T9.4 (тесты `AgileGate` и меню
kanban-проекта), T12.2–T12.4 (нестрогие e2e доски).
## Non-Goals
- Правки Jira из веба — по-прежнему только просмотр.
- Изменения API, схемы, агента, MCP: все данные (`epic_key`, `issue_type`, `assignee`, `done`, колонки) уже есть в
`GET /agile`.
- Настройки доски AWPCA в Jira («Протестировано» в «Нужно сделать», «Снято» без колонки, опечатка «К тестирвоанию») —
делаются в Jira владельцем.
- Прочие пункты hub#111 (сервер, MCP, агент, тесты API) — остаются в follow-up.
## Impact
- Спека `web-app`, требование «Спринты и бэклог» — переписано.
- Код: `web/src/modules/jira/agile/*`, значки в `web/src/ui/icons.tsx`; новые значки — перенести в kit скилла
`aura-design` (правки стиля хаба переносятся в kit).
- Тесты: vitest страниц и модели фильтров, e2e `web/e2e/agile.spec.ts` (широкий экран, высота колонок, запоминание).

View File

@ -0,0 +1,130 @@
## MODIFIED Requirements
### Requirement: Спринты и бэклог
Модуль jira SHALL показывать для проекта с доской страницы по образцу Jira — только для просмотра: на них MUST NOT быть
действий, меняющих Jira (перетаскивания, кнопок спринтов, смены статуса или ранга). Пункты меню группы «Jira» —
«Связи», «Задачи», «Лента» и по типу доски текущего проекта (`board_type` из `GET /projects`): `scrum` — «Бэклог» и
«Спринт»; `kanban` — «Kanban»; доски нет или тип неизвестен — новых пунктов нет, а прямой переход на их адрес
показывает «У проекта нет scrum-доски» («…kanban-доски» для «Kanban») со ссылкой на «Связи». Данные — `GET
/agile?project=<текущий>`; если доска есть, но её данных от агента нет (`board = null` или `board.fetched_at = null`) —
«Нужен агент v15 и обновление из Jira» с кнопкой «Обновить» блока состояния модуля; данные доски есть (`fetched_at` задан), а колонок нет (`columns = null`) — «Не удалось
прочитать настройки доски из Jira» и та же кнопка. Пока проекты не загружены, условные пункты меню скрыты; смена проекта
на странице, которой у нового проекта нет, оставляет адрес и показывает сообщение «У проекта нет …-доски». Даты
спринтов (ISO-8601 Jira) показываются «ДД.ММ» в часовом поясе браузера; «осталось N дн.» — от сегодня до конца спринта.
Строка задачи и карточка задачи на этих страницах: ключ, значок типа, тема, исполнитель (или «—»), оценка (число или
«—»); выполненная — приглушена. Щелчок открывает карточку задачи (окно поверх страницы, как в списке задач). Оценки и
их суммы SHALL показываться числом не более чем с двумя знаками после запятой без хвостовых нулей (`0.1 + 0.2` —
«0.3», `3` — «3»).
**Значок типа** SHALL различать по `issue_type` без учёта регистра: эпик («Эпик», «Epic», «Фича»), история
(«История», «Story»), задача («Задача», «Task»), ошибка («Ошибка», «Bug»), подзадача («Подзадача», «Sub-task»,
«Subtask»); прочие и пустой тип — нейтральный значок. Значок — цветной квадрат с символом цвета `--color-text-on-primary`, контрастным в обеих темах, у каждого вида свои
цвет и символ (эпик — молния, история — закладка, задача — галочка, ошибка — круг, подзадача — два квадрата), в обеих
темах; название типа — в `aria-label` и подсказке.
**Панель фильтров** — над содержимым «Бэклога», «Спринта» и «Kanban»: поиск «Ключ или тема» (подстрока без учёта
регистра в ключе или теме), выбор «Исполнитель» («все», «Без исполнителя», исполнители задач страницы по алфавиту),
переключатель «Скрыть выполненные» (только «Бэклог» и «Спринт»); на «Бэклоге» — кнопка «Эпики» (показать или скрыть
панель эпиков); на «Спринте» — выбор спринтов; на «Спринте» и «Kanban» — меню «Колонки». Если задан хотя бы один фильтр
(поиск, исполнитель, эпик, «Скрыть выполненные») — кнопка «Сбросить фильтры», снимающая их все. Фильтры действуют вместе
(И). Сохранённое значение исполнителя или эпика, которого нет в данных, SHALL оставаться выбранным и видимым в списке.
**Запоминание:** свёрнутые секции, выбранный эпик, исполнитель, «Скрыть выполненные», видимость панели эпиков и скрытые
колонки SHALL храниться в `localStorage` под ключом `sdlc-hub-agile` — JSON по метке проекта и странице (`backlog`,
`sprint`, `kanban`); у разных страниц и проектов свои значения. Поиск и выбор спринтов не запоминаются. Нечитаемое или
закрытое хранилище — значения по умолчанию, страница работает.
**«Бэклог»** (`/app/jira/backlog`), сверху вниз: секции активных спринтов, будущих спринтов, «Бэклог», затем
свёрнутый список «Прошлые спринты (N)». Заголовок секции спринта: имя, состояние, даты «ДД.ММ–ДД.ММ», цель (если есть),
«N задач», «S SP (выполнено D)» и «без оценки: U», если `U > 0` — итоги спринта, фильтры их не меняют; если фильтры
скрыли часть задач секции — ещё «показано K». Задачи секции — по рангу. Секция без задач показывает «Нет задач»; все
задачи скрыты фильтрами — «Нет задач по фильтрам». Любую секцию (спринта и «Бэклог») SHALL можно свернуть и развернуть
щелчком по заголовку (кнопка с `aria-expanded`); по умолчанию развёрнуты все. Прошлый спринт в списке — строка «имя ·
закрыт ДД.ММ · выполнено X из N · S SP»; раскрытие показывает его задачи с пометкой «не выполнено» у невыполненных.
Эпики (задачи вида «эпик») MUST NOT показываться строками в секциях «Бэклога». Панель «Эпики» — слева от секций:
пункт «Все задачи», затем эпики — каждая задача-эпик из данных страницы по рангу, затем эпики, на которые ссылается
`epic_key` задач, но строки которых в данных нет, по ключу; в конце — «Без эпика». Пункт эпика: тема (или ключ, если
темы нет), ключ, число задач эпика в секциях активных и будущих спринтов и «Бэклога». Выбор пункта (кнопка с
`aria-pressed`) фильтрует все секции, включая прошлые спринты; повторный выбор выбранного эпика возвращает «Все
задачи». При «Скрыть выполненные» выполненные эпики в панели не показываются (кроме выбранного). На узком экране
(контейнер страницы уже 900 px) панель SHALL становиться выбором «Эпик» в панели фильтров.
«Скрыть выполненные» на «Бэклоге» скрывает выполненные задачи в секциях активных и будущих спринтов и «Бэклога»;
прошлые спринты показываются полностью.
**«Спринт»** (`/app/jira/sprint`): выбранные спринты доски — по умолчанию все активные. Выбор спринтов — список с
отметками: активные, затем будущие (по дате начала, без даты — в конце); выбор хранится в адресе
`?sprint=<id>[,<id>…]` (переход без новой записи истории); идентификаторы, которых нет среди активных и будущих,
отбрасываются, если не осталось ни одного — все активные. Над доской — заголовок каждого выбранного спринта (имя,
даты, «осталось N дн.» или «срок прошёл» — только у активного, цель, суммы оценок), под ними колонки доски Jira с числом
задач; задачи доски — задачи выбранных спринтов, карточки в колонке — по рангу; колонка «Без колонки» — для статусов вне
настроек доски, только если в ней есть задачи. Нет активного спринта и ничего не выбрано — «Нет активного спринта» и
ссылка на «Бэклог»; если есть будущие спринты, выбор спринтов при этом доступен.
**«Kanban»** (`/app/jira/kanban`): колонки доски из `board_issues`, карточки по рангу, у последней колонки подпись
«за 14 дней»; если `kanban_backlog = true` — над колонками свёрнутый список «Бэклог (N)» по рангу; поиск и исполнитель
действуют и на него.
**Колонки доски** («Спринт» и «Kanban»): заголовок колонки — `h3`. Меню «Колонки» — отметки по колонкам доски (и «Без
колонки», если она есть); снятая отметка скрывает колонку, последнюю видимую скрыть нельзя (отметка недоступна); если
скрыты колонки — на кнопке их число. Доска SHALL занимать высоту окна под заголовком и панелью фильтров (не меньше
360 px; на окне уже 640 px — не меньше 240 px), все колонки — во всю высоту доски; карточки, не помещающиеся по высоте, прокручиваются внутри своей колонки.
Колонки, не помещающиеся по ширине, SHALL прокручиваться по горизонтали внутри доски, не страницы, и полоса горизонтальной
прокрутки доски SHALL находиться в пределах окна при любом числе карточек. Заголовок вкладки — «Бэклог», «Спринт»,
«Kanban» по общему правилу оболочки.
#### Scenario: Меню scrum-проекта
- **WHEN** текущий проект `AWPCA`, `board_type = scrum`
- **THEN** в группе «Jira» пункты «Связи», «Бэклог», «Спринт», «Задачи», «Лента»; пункта «Kanban» нет
#### Scenario: Проект без доски
- **WHEN** текущий проект `STOCKS_DRRK` без доски, открыт `/app/jira/backlog?project=STOCKS_DRRK`
- **THEN** пунктов «Бэклог», «Спринт», «Kanban» в меню нет; страница — «У проекта нет scrum-доски» со ссылкой на «Связи»
#### Scenario: Бэклог только смотрит
- **WHEN** открыт «Бэклог» `AWPCA`
- **THEN** на странице нет перетаскиваемых элементов; кнопки и поля — только свёртка секций, панель фильтров, пункты
панели эпиков и открытие карточки задачи; ни одно из них не ставит команд
#### Scenario: Прошлый спринт
- **WHEN** спринт `AWPCA_Sprint_2_VGO` закрыт, в нём 73 задачи, 61 в последней колонке, оценки у 10 задач на 25 SP
- **THEN** в «Прошлые спринты» строка «AWPCA_Sprint_2_VGO · закрыт ДД.ММ · выполнено 61 из 73 · 25 SP»; раскрытие —
73 задачи, 12 с пометкой «не выполнено»
#### Scenario: Агент без досок
- **WHEN** `AWPCA` scrum, агент v14 (данных доски нет), открыт «Спринт»
- **THEN** «Нужен агент v15 и обновление из Jira» и кнопка «Обновить»
#### Scenario: Свёрнутый активный спринт
- **WHEN** на «Бэклоге» свёрнута секция активного спринта, страница перезагружена
- **THEN** секция свёрнута (видны заголовок и итоги, задач нет), остальные развёрнуты
#### Scenario: Фильтр по эпику
- **WHEN** в бэклоге 3 задачи эпика `AURORA-10` и 5 других, в активном спринте 2 задачи `AURORA-10`; в панели «Эпики»
выбран `AURORA-10`
- **THEN** в секции спринта 2 задачи и «показано 2», в «Бэклоге» 3; строки самого `AURORA-10` в секциях нет; у пункта
`AURORA-10` в панели — 5
#### Scenario: Скрыть выполненные
- **WHEN** в активном спринте 10 задач, 4 выполнены; включено «Скрыть выполненные»
- **THEN** в секции 6 задач, заголовок — «10 задач» и «показано 6»; «Прошлые спринты» показывают все свои задачи
#### Scenario: Исполнитель на доске спринта
- **WHEN** на «Спринте» выбран исполнитель `ivanov`
- **THEN** на доске только его карточки; числа в заголовках колонок — по видимым карточкам; на «Бэклоге» исполнитель не
выбран (свой фильтр у каждой страницы)
#### Scenario: Будущий спринт на доске
- **WHEN** у доски активный спринт 101 и будущий 102; в выборе спринтов отмечен и 102
- **THEN** адрес — `?sprint=101,102`; над доской два заголовка, «осталось N дн.» только у 101; на доске задачи обоих
#### Scenario: Скрытая колонка
- **WHEN** на «Спринте» в меню «Колонки» снята отметка «Выполнено», страница перезагружена
- **THEN** колонки «Выполнено» нет, на кнопке «Колонки» — 1; остальные колонки на месте
#### Scenario: Широкая доска
- **WHEN** окно 2560 × 1300 px, у доски 12 колонок, в одной из них 40 карточек
- **THEN** все колонки одной высоты и помещаются в окно по высоте; полоса горизонтальной прокрутки доски видна в окне;
прокрутка доски вправо до конца показывает «Без колонки» целиком; страница горизонтально не прокручивается

View File

@ -2,18 +2,23 @@ import { expect, test } from "@playwright/test";
import { login } from "./hub.ts";
// экраны доски: «Бэклог», «Спринт», «Kanban» (spec web-app «Спринты и бэклог»); данные — сид тестового хаба (R-27)
const side = (page: import("@playwright/test").Page) => page.getByRole("complementary", { name: "Панель приложения" });
type Page = import("@playwright/test").Page;
const side = (page: Page) => page.getByRole("complementary", { name: "Панель приложения" });
// строка списка / карточка колонки по ключу задачи (ссылка с точным именем), без привязки к порядку в DOM
const itemOf = (page: Page, key: string) => page.getByRole("listitem").filter({ has: page.getByRole("link", { name: key, exact: true }) });
const colOf = (page: Page, name: string) => page.getByRole("region", { name, exact: true });
test("«Бэклог» AWPCA: секции, «Прошлые спринты (1)», щелчок открывает карточку", async ({ page }) => {
await login(page, "/app/jira/backlog?project=AWPCA");
await expect(page.getByRole("heading", { name: "AWPCA_Спринт 2" })).toBeVisible();
await expect(page.getByRole("heading", { name: "AWPCA_Спринт 3" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Бэклог", exact: true, level: 2 })).toBeVisible();
const past = page.getByText("Прошлые спринты (1)");
const past = page.getByText("Прошлые спринты (1)", { exact: true });
await expect(past).toBeVisible();
await past.click();
await page.getByText("AWPCA_Спринт 1").first().click();
const row = page.locator(".agile-row").filter({ hasText: "AURORA-9001" }).last();
const pastSprint = page.locator("details.agile-past__sprint").filter({ has: page.locator("summary", { hasText: "AWPCA_Спринт 1" }) });
await pastSprint.locator("summary").click();
const row = pastSprint.getByRole("listitem").filter({ has: page.getByRole("link", { name: "AURORA-9001", exact: true }) });
await expect(row).toBeVisible();
await row.click();
await expect(page).toHaveURL(/\/app\/jira\/issues\/AURORA-9001/);
@ -28,9 +33,9 @@ test("«Спринт» AWPCA: колонки доски, «осталось 7 д
await expect(page.getByRole("heading", { name: "AWPCA_Спринт 2" })).toBeVisible();
await expect(page.getByText(/осталось \d+ дн\./)).toBeVisible();
for (const name of ["К выполнению", "В работе", "Готово"]) {
await expect(page.locator(".agile-col__name", { hasText: name })).toBeVisible();
await expect(page.getByRole("heading", { name, exact: true, level: 3 })).toBeVisible();
}
await page.locator(".agile-card").filter({ hasText: "AURORA-9001" }).click();
await itemOf(page, "AURORA-9001").click();
await expect(page).toHaveURL(/\/app\/jira\/issues\/AURORA-9001/);
await expect(page.getByRole("dialog")).toBeVisible();
await page.keyboard.press("Escape");
@ -39,19 +44,19 @@ test("«Спринт» AWPCA: колонки доски, «осталось 7 д
test("«Kanban»: «Бэклог (1)», колонки «В работе» и «Готово», подпись «за 14 дней»", async ({ page }) => {
await login(page, "/app/jira/kanban?project=KANBAN_E2E");
await expect(page.getByText("Бэклог (1)")).toBeVisible();
await expect(page.locator(".agile-col__name", { hasText: "В работе" })).toBeVisible();
await expect(page.locator(".agile-col__name", { hasText: "Готово" })).toBeVisible();
await expect(page.locator(".agile-col__name", { hasText: "Бэклог" })).toHaveCount(0);
await expect(page.getByText("за 14 дней")).toBeVisible();
await expect(page.locator(".agile-card").filter({ hasText: "AURORA-9102" })).toBeVisible();
await expect(page.locator(".agile-card").filter({ hasText: "AURORA-9103" })).toBeVisible();
await expect(page.getByText("Бэклог (1)", { exact: true })).toBeVisible();
await expect(colOf(page, "В работе")).toBeVisible();
await expect(colOf(page, "Готово")).toBeVisible();
await expect(colOf(page, "Бэклог")).toHaveCount(0);
await expect(page.getByText("за 14 дней", { exact: true })).toBeVisible();
await expect(colOf(page, "В работе").getByRole("link", { name: "AURORA-9102", exact: true })).toBeVisible();
await expect(colOf(page, "Готово").getByRole("link", { name: "AURORA-9103", exact: true })).toBeVisible();
});
test("без scrum-доски: /app/jira/backlog?project=KANBAN_E2E — «У проекта нет scrum-доски», ссылка «Связи»", async ({ page }) => {
await login(page, "/app/jira/backlog?project=KANBAN_E2E");
await expect(page.getByText("У проекта нет scrum-доски")).toBeVisible();
await page.getByRole("link", { name: "Связи", exact: true }).last().click();
await page.getByRole("main").getByRole("link", { name: "Связи", exact: true }).click();
await expect(page).toHaveURL(/\/app\/jira\/links\?project=KANBAN_E2E/);
});
@ -69,13 +74,115 @@ test("меню по проекту: у AWPCA «Бэклог» и «Спринт
test("900 px: колонки «Спринта» прокручиваются внутри доски, страница без горизонтальной прокрутки", async ({ page }) => {
await page.setViewportSize({ width: 900, height: 900 });
await login(page, "/app/jira/sprint?project=AWPCA");
await expect(page.locator(".agile-col").first()).toBeVisible();
await expect(colOf(page, "К выполнению")).toBeVisible();
const m = await page.evaluate(() => {
const d = document.documentElement, c = document.querySelector<HTMLElement>(".agile-columns")!;
return { doc: [d.scrollWidth, d.clientWidth], cols: [c.scrollWidth, c.clientWidth],
return { doc: [d.scrollWidth, d.clientWidth], cols: [c.scrollWidth, c.clientWidth], bottom: c.getBoundingClientRect().bottom,
gutter: c.getBoundingClientRect().left - document.querySelector(".agile")!.getBoundingClientRect().left };
});
expect(m.gutter, "колонки не получают второй отступ внутри страницы (T10.1)").toBe(0);
expect(m.doc[0]).toBeLessThanOrEqual(m.doc[1]);
expect(m.cols[0]).toBeGreaterThan(m.cols[1]);
expect(m.bottom, "нижний край доски в окне").toBeLessThanOrEqual(900);
});
test("«Широкая доска» 2560×1300: колонки одной высоты, доска в окне, горизонтальная прокрутка внутри, «Без колонки» достижима", async ({ page }) => {
await page.setViewportSize({ width: 2560, height: 1300 });
await login(page, "/app/jira/sprint?project=WIDE_E2E");
await expect(colOf(page, "Колонка 1")).toBeVisible();
await expect(colOf(page, "Без колонки")).toHaveCount(1);
const heights = () => page.evaluate(() => [...document.querySelectorAll<HTMLElement>(".agile-col")].map((c) => c.getBoundingClientRect().height));
await expect.poll(async () => { const h = await heights(); return Math.max(...h) - Math.min(...h); }, { message: "высоты колонок равны" }).toBeLessThanOrEqual(1);
const m = await page.evaluate(() => {
const board = document.querySelector<HTMLElement>(".agile-columns")!;
const heights = [...document.querySelectorAll<HTMLElement>(".agile-col")].map((c) => c.getBoundingClientRect().height);
return { heights, bottom: board.getBoundingClientRect().bottom, inner: window.innerHeight, width: board.getBoundingClientRect().width,
sw: board.scrollWidth, cw: board.clientWidth, count: heights.length };
});
expect(m.count, "11 колонок и «Без колонки»").toBe(12);
expect(m.bottom, "нижний край доски в окне").toBeLessThanOrEqual(m.inner);
expect(m.sw, "доска прокручивается по горизонтали").toBeGreaterThan(m.cw);
expect(m.width, "доска шире 1440 px (нет max-width)").toBeGreaterThan(1440);
await page.evaluate(() => { const b = document.querySelector<HTMLElement>(".agile-columns")!; b.scrollLeft = b.scrollWidth; });
const extra = colOf(page, "Без колонки");
await expect(extra).toBeInViewport({ ratio: 1 });
const doc = await page.evaluate(() => [document.documentElement.scrollWidth, document.documentElement.clientWidth]);
expect(doc[0], "страница без горизонтальной прокрутки").toBeLessThanOrEqual(doc[1]);
});
test("узкий контейнер: на 900 px вместо панели «Эпики» — выбор «Эпик», на 1600 px — панель и кнопка", async ({ page }) => {
await page.setViewportSize({ width: 900, height: 900 });
await login(page, "/app/jira/backlog?project=AWPCA");
await expect(page.getByRole("heading", { name: "AWPCA_Спринт 2" })).toBeVisible();
await expect(page.getByRole("complementary", { name: "Эпики" })).toBeHidden();
await expect(page.getByRole("button", { name: "Эпики", exact: true })).toBeHidden();
await expect(page.getByRole("combobox", { name: "Эпик" })).toBeVisible();
await page.setViewportSize({ width: 1600, height: 900 });
await expect(page.getByRole("complementary", { name: "Эпики" })).toBeVisible();
await expect(page.getByRole("button", { name: "Эпики", exact: true })).toBeVisible();
await expect(page.getByRole("combobox", { name: "Эпик" })).toBeHidden();
});
test("«Бэклог» AWPCA: панель «Эпики», выбор эпика оставляет только его задачи, свёртка секции переживает перезагрузку", async ({ page }) => {
await login(page, "/app/jira/backlog?project=AWPCA");
const panel = page.getByRole("complementary", { name: "Эпики" });
await expect(panel).toBeVisible();
await expect(itemOf(page, "AURORA-9004")).toBeVisible();
await panel.getByRole("button", { name: /AURORA-9100/ }).click();
await expect(itemOf(page, "AURORA-9001")).toBeVisible();
await expect(itemOf(page, "AURORA-9004")).toHaveCount(0);
await expect(itemOf(page, "AURORA-9008")).toHaveCount(0);
await panel.getByRole("button", { name: "Все задачи" }).click();
await expect(itemOf(page, "AURORA-9004")).toBeVisible();
const toggle = page.getByRole("button", { name: "AWPCA_Спринт 3", exact: true });
await expect(toggle).toHaveAttribute("aria-expanded", "true");
await toggle.click();
await expect(toggle).toHaveAttribute("aria-expanded", "false");
await expect(itemOf(page, "AURORA-9004")).toHaveCount(0);
await page.reload();
await expect(page.getByRole("button", { name: "AWPCA_Спринт 3", exact: true })).toHaveAttribute("aria-expanded", "false");
await expect(itemOf(page, "AURORA-9004")).toHaveCount(0);
});
test("«Спринт» AWPCA: выбор двух спринтов пишет sprint=102,103 в адрес, скрытая колонка «Готово» переживает перезагрузку", async ({ page }) => {
await login(page, "/app/jira/sprint?project=AWPCA");
await expect(colOf(page, "Готово")).toBeVisible();
await page.getByRole("button", { name: "AWPCA_Спринт 2" }).click();
await page.getByRole("checkbox", { name: /AWPCA_Спринт 3/ }).click();
await expect(page).toHaveURL(/sprint=102(,|%2C)103/);
await expect(page.getByRole("heading", { name: "AWPCA_Спринт 3" })).toBeVisible();
await page.keyboard.press("Escape");
await page.getByRole("button", { name: "Колонки" }).click();
await page.getByRole("checkbox", { name: "Готово", exact: true }).click();
await expect(colOf(page, "Готово")).toHaveCount(0);
await page.reload();
await expect(colOf(page, "В работе")).toBeVisible();
await expect(colOf(page, "Готово")).toHaveCount(0);
});
test("390×800: документ без горизонтальной прокрутки, нижний край доски в окне (T8.1)", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 800 });
await login(page, "/app/jira/sprint?project=WIDE_E2E");
await expect(colOf(page, "Колонка 1")).toBeVisible();
const m = await page.evaluate(() => {
const d = document.documentElement, b = document.querySelector<HTMLElement>(".agile-columns")!;
return { doc: [d.scrollWidth, d.clientWidth], bottom: b.getBoundingClientRect().bottom, inner: window.innerHeight, sw: b.scrollWidth, cw: b.clientWidth };
});
expect(m.doc[0], JSON.stringify(m)).toBeLessThanOrEqual(m.doc[1]);
expect(m.bottom, JSON.stringify(m)).toBeLessThanOrEqual(m.inner);
expect(m.sw).toBeGreaterThan(m.cw);
});
test("панель фильтров на 1600 px: «Исполнитель» и поиск на одной строке, подпись видна (I-2, M-2)", async ({ page }) => {
await page.setViewportSize({ width: 1600, height: 900 });
await login(page, "/app/jira/sprint?project=AWPCA");
const search = page.getByRole("searchbox", { name: "Поиск по ключу или теме" });
const select = page.getByRole("combobox", { name: "Исполнитель" });
await expect(select).toBeVisible();
await expect(page.locator(".agile-toolbar__field").getByText("Исполнитель", { exact: true })).toBeVisible();
const mid = async (l: typeof search) => { const b = (await l.boundingBox())!; return b.y + b.height / 2; };
expect(Math.abs((await mid(search)) - (await mid(select))), "центры по вертикали").toBeLessThanOrEqual(2);
const bar = (await page.locator(".agile-toolbar").boundingBox())!;
const sel = (await select.boundingBox())!;
expect(sel.width, "выбор не растянут на строку").toBeLessThan(bar.width / 2);
});

View File

@ -36,11 +36,25 @@ for n, i in enumerate(issues, 1):
i["story_points"] = float(n % 5 + 1)
today = date.today()
iso = lambda d, hhmm: f"{d.isoformat()}T{hhmm}:00.000+03:00"
now_iso = iso(today, "12:00")
# фиксированная дата «Готово» — вчера в полдень: «за 14 дней» от «сегодня 12:00» не зависит (T12.2)
done_iso = iso(today - timedelta(days=1), "12:00")
issues += [{"key": k, "id": k, "summary": f"Задача канбана {k[-4:]}", "issue_type": "Задача", "status": st, "priority": "Средний",
"labels": ["KANBAN_E2E"], "rank": f"0|k{n:04d}:", "sprint_ids": [], **({"jira_updated": now_iso} if st == "Готово" else {})}
"labels": ["KANBAN_E2E"], "rank": f"0|k{n:04d}:", "sprint_ids": [], **({"jira_updated": done_iso} if st == "Готово" else {})}
for n, (k, st) in enumerate([("AURORA-9101", "Backlog"), ("AURORA-9102", "В работе"), ("AURORA-9103", "Готово")], 1)]
jira_projects.create(conn, "KANBAN_E2E", "Канбан e2e", 5120)
# WIDE_E2E: широкая scrum-доска (11 колонок, 40 задач в одной, статус вне колонок) — воспроизводит баг прокрутки (#112, T10.3)
WIDE_COLS = [f"Колонка {n}" for n in range(1, 12)]
wide_statuses = [f"Статус {n}" for n in range(1, 12)]
wide = []
for n in range(1, 41): # 40 задач в первой колонке
wide.append((f"AURORA-92{n:02d}", wide_statuses[0]))
for c in range(1, 11): # по 1-3 в остальных
for j in range(1, c % 3 + 2):
wide.append((f"AURORA-93{c:02d}{j}", wide_statuses[c]))
wide.append(("AURORA-9400", "Чужой статус")) # вне колонок -> «Без колонки»
issues += [{"key": k, "id": k, "summary": f"Задача широкой доски {k[-4:]}", "issue_type": "Задача", "status": st, "priority": "Средний",
"labels": ["WIDE_E2E"], "rank": f"0|w{n:04d}:", "sprint_ids": [201]} for n, (k, st) in enumerate(wide, 1)]
jira_projects.create(conn, "WIDE_E2E", "Широкая доска e2e", 6100)
boards = [
{"id": 4216, "type": "scrum", "kanban_backlog": None,
"columns": [{"name": "К выполнению", "statuses": ["Backlog", "К выполнению", "Ожидание", "Ожидание разработки"]},
@ -53,6 +67,10 @@ boards = [
{"id": 5120, "type": "kanban", "kanban_backlog": True,
"columns": [{"name": "Бэклог", "statuses": ["Backlog"]}, {"name": "В работе", "statuses": ["В работе"]},
{"name": "Готово", "statuses": ["Готово"]}]},
{"id": 6100, "type": "scrum", "kanban_backlog": None,
"columns": [{"name": n, "statuses": [st]} for n, st in zip(WIDE_COLS, wide_statuses)],
"sprints": [{"id": 201, "name": "WIDE_Спринт 1", "state": "active", "start_date": iso(today - timedelta(days=3), "09:00"),
"end_date": iso(today + timedelta(days=11), "12:00"), "complete_date": None, "goal": None}]},
]
mirror.apply_state(conn, issues, links, neighbor_keys=[i["key"] for i in snap["issues"] if i["neighbor"]], boards=boards)
for i in snap["issues"]:

View File

@ -0,0 +1,69 @@
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { createMemoryRouter } from "react-router";
import { beforeEach, describe, expect, it } from "vitest";
import { App } from "../../../App.tsx";
import { setToken } from "../../../auth/token.ts";
import settings from "../board/__fixtures__/settings.json";
import { routes } from "../../../routes.tsx";
import { json, mockFetch } from "../../../test/fetchMock.ts";
import type { AgileResponse } from "../api/types.ts";
const S = { label: "AWPCA", name: "АРМ КА", board_id: 4216, board_type: "scrum", created_at: "", issues_count: 9, jira_synced_at: null };
const K = { label: "KAN", name: "Канбан", board_id: 5120, board_type: "kanban", created_at: "", issues_count: 4, jira_synced_at: null };
const data: AgileResponse = {
board: { id: 4216, type: "scrum", columns: [{ name: "Новая", statuses: ["Новая"] }], kanban_backlog: null, fetched_at: "2026-10-24T10:00:00+00:00" },
sprints: [], backlog: [], board_issues: [],
};
const open = (path: string) => {
const router = createMemoryRouter(routes, { initialEntries: [path], basename: "/app" });
render(<App router={router} />);
return router;
};
const base = (method: string, pathname: string, projects: unknown[]) => {
const k = `${method} ${pathname}`;
if (k === "GET /projects") return json(200, { projects });
if (k === "GET /settings") return json(200, settings);
if (k === "GET /issues") return json(200, { issues: [] });
if (k === "GET /batches") return json(200, { batches: [] });
if (k === "GET /agents") return json(200, { agents: [] });
return null;
};
describe("AgileGate (T9.4)", () => {
beforeEach(() => { setToken("tok"); localStorage.clear(); });
it("пока /agile не ответил — «Загрузка…»", async () => {
mockFetch((method, url) => {
if (`${method} ${url.pathname}` === "GET /agile") return new Promise<Response>(() => {});
return base(method, url.pathname, [S]) ?? json(404, { error: "not_found" });
});
open("/app/jira/sprint?project=AWPCA");
expect(await screen.findByText("Загрузка…")).toBeInTheDocument();
});
it("ошибка /agile — текст ошибки и «Повторить», щелчок повторяет запрос", async () => {
let calls = 0;
mockFetch((method, url) => {
if (`${method} ${url.pathname}` === "GET /agile") { calls += 1; return calls === 1 ? json(500, { error: "hub_down" }) : json(200, data); }
return base(method, url.pathname, [S]) ?? json(404, { error: "not_found" });
});
open("/app/jira/sprint?project=AWPCA");
const retry = await screen.findByRole("button", { name: "Повторить" });
expect(screen.queryByText("Загрузка…")).not.toBeInTheDocument();
expect(await screen.findByText("хаб ответил 500")).toBeInTheDocument();
await userEvent.click(retry);
await waitFor(() => expect(calls).toBe(2));
await waitFor(() => expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument());
});
it("меню kanban-проекта: «Kanban» есть, «Бэклог» и «Спринт» нет", async () => {
mockFetch((method, url) => base(method, url.pathname, [K]) ?? json(200, { ...data, board: { ...data.board, type: "kanban" }, sprints: [], backlog: null }));
open("/app/jira/kanban?project=KAN");
const menu = await screen.findByRole("group", { name: "Jira" });
expect(await within(menu).findByRole("link", { name: "Kanban" })).toBeInTheDocument();
expect(within(menu).queryByRole("link", { name: "Бэклог" })).not.toBeInTheDocument();
expect(within(menu).queryByRole("link", { name: "Спринт" })).not.toBeInTheDocument();
});
});

View File

@ -0,0 +1,116 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { AGILE_KEY } from "./agilePref.ts";
import { assigneeOptions, type AgilePage } from "./filters.ts";
import { AgileToolbar } from "./AgileToolbar.tsx";
import { useAgileView } from "./useAgileView.ts";
import type { AgileIssue } from "../api/types.ts";
const issues = [{ assignee: "ivanov" }, { assignee: "petrov" }] as AgileIssue[];
function Harness({ label = "AWPCA", page = "backlog", showHideDone = true }: { label?: string; page?: AgilePage; showHideDone?: boolean }) {
const { view, set, setQ, filter, reset } = useAgileView(label, page);
return (
<>
<AgileToolbar filter={filter} setQ={setQ} set={set} onReset={reset} assignees={assigneeOptions(issues, view.assignee)} showHideDone={showHideDone}>
<span>слот</span>
</AgileToolbar>
<output data-testid="q">{filter.q}</output>
</>
);
}
const stored = () => JSON.parse(localStorage.getItem(AGILE_KEY) ?? "null");
const search = () => screen.getByLabelText("Поиск по ключу или теме") as HTMLInputElement;
const assignee = () => screen.getByLabelText("Исполнитель") as HTMLSelectElement;
beforeEach(() => localStorage.clear());
afterEach(cleanup);
describe("AgileToolbar", () => {
it("ввод в поиск меняет filter.q и не сохраняется", () => {
render(<Harness />);
fireEvent.change(search(), { target: { value: "abc" } });
expect(screen.getByTestId("q").textContent).toBe("abc");
expect(localStorage.getItem(AGILE_KEY)).toBeNull();
});
it("исполнитель сохраняется по метке и странице", () => {
render(<Harness />);
fireEvent.change(assignee(), { target: { value: "petrov" } });
expect(stored().AWPCA.backlog.assignee).toBe("petrov");
expect(stored().AWPCA.backlog).not.toHaveProperty("q");
});
it("«Сбросить фильтры» появляется после ввода и снимает всё", () => {
render(<Harness />);
expect(screen.queryByText("Сбросить фильтры")).toBeNull();
fireEvent.change(search(), { target: { value: "abc" } });
fireEvent.change(assignee(), { target: { value: "petrov" } });
fireEvent.click(screen.getByLabelText("Скрыть выполненные"));
fireEvent.click(screen.getByText("Сбросить фильтры"));
expect(search().value).toBe("");
expect(assignee().value).toBe("");
expect((screen.getByLabelText("Скрыть выполненные") as HTMLInputElement).checked).toBe(false);
expect(screen.queryByText("Сбросить фильтры")).toBeNull();
expect(stored().AWPCA.backlog).toMatchObject({ assignee: "", epic: "", hideDone: false });
});
it("showHideDone=false — флажка нет; слот показан", () => {
render(<Harness showHideDone={false} />);
expect(screen.queryByLabelText("Скрыть выполненные")).toBeNull();
expect(screen.getByText("слот")).toBeTruthy();
});
it("повторный монтаж с той же меткой и страницей восстанавливает исполнителя и флажок", () => {
const a = render(<Harness />);
fireEvent.change(assignee(), { target: { value: "ivanov" } });
fireEvent.click(screen.getByLabelText("Скрыть выполненные"));
a.unmount();
render(<Harness />);
expect(assignee().value).toBe("ivanov");
expect((screen.getByLabelText("Скрыть выполненные") as HTMLInputElement).checked).toBe(true);
});
it("другая страница или метка — свои значения", () => {
const a = render(<Harness />);
fireEvent.change(assignee(), { target: { value: "ivanov" } });
a.unmount();
render(<Harness page="sprint" />);
expect(assignee().value).toBe("");
});
it("смена метки и страницы без размонтирования: q сброшен, чужие значения не протекают, прежние данные целы", () => {
const r = render(<Harness />);
fireEvent.change(assignee(), { target: { value: "ivanov" } });
fireEvent.click(screen.getByLabelText("Скрыть выполненные"));
fireEvent.change(search(), { target: { value: "abc" } });
const before = localStorage.getItem(AGILE_KEY);
r.rerender(<Harness label="STOCKS" />);
expect(search().value).toBe("");
expect(assignee().value).toBe("");
expect((screen.getByLabelText("Скрыть выполненные") as HTMLInputElement).checked).toBe(false);
expect(localStorage.getItem(AGILE_KEY)).toBe(before);
r.rerender(<Harness label="STOCKS" page="kanban" />);
expect(assignee().value).toBe("");
expect(localStorage.getItem(AGILE_KEY)).toBe(before);
r.rerender(<Harness />);
expect(assignee().value).toBe("ivanov");
expect(search().value).toBe("");
});
it("сброс сохраняет collapsed, hiddenColumns и epicsPanel", () => {
localStorage.setItem(AGILE_KEY, JSON.stringify({ AWPCA: { backlog: {
assignee: "ivanov", epic: "E-1", hideDone: true, collapsed: ["E-1"], hiddenColumns: ["Done"], epicsPanel: false } } }));
render(<Harness />);
fireEvent.click(screen.getByText("Сбросить фильтры"));
expect(stored().AWPCA.backlog).toEqual({
assignee: "", epic: "", hideDone: false, collapsed: ["E-1"], hiddenColumns: ["Done"], epicsPanel: false });
});
it("showHideDone=false: сохранённый hideDone не показывает «Сбросить фильтры»", () => {
localStorage.setItem(AGILE_KEY, JSON.stringify({ AWPCA: { backlog: { hideDone: true } } }));
render(<Harness showHideDone={false} />);
expect(screen.queryByText("Сбросить фильтры")).toBeNull();
});
});

View File

@ -0,0 +1,28 @@
// панель фильтров экранов доски: поиск, исполнитель, «Скрыть выполненные», слот страницы, «Сбросить фильтры»
import type { ReactNode } from "react";
import { Button, Checkbox, Panel, SearchInput, Select } from "../../../ui/index.ts";
import { hasFilters, type AgileView, type IssueFilter, type Opt } from "./filters.ts";
export function AgileToolbar({ filter, setQ, set, onReset, assignees, showHideDone, children }: {
filter: IssueFilter; setQ(v: string): void; set(patch: Partial<AgileView>): void; onReset(): void;
assignees: Opt[]; showHideDone: boolean; children?: ReactNode;
}) {
return (
<Panel className="agile-toolbar" padding="md">
<div className="agile-toolbar__search">
<SearchInput label="Поиск по ключу или теме" placeholder="Ключ или тема" value={filter.q} onChange={setQ} />
</div>
<label className="agile-toolbar__field">
<span>Исполнитель</span>
<Select aria-label="Исполнитель" value={filter.assignee} onChange={(e) => set({ assignee: e.target.value })}>
{assignees.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</Select>
</label>
{showHideDone && (
<Checkbox label="Скрыть выполненные" checked={filter.hideDone} onChange={(e) => set({ hideDone: e.target.checked })} />
)}
{children}
{hasFilters({ ...filter, hideDone: filter.hideDone && showHideDone }) && <Button variant="ghost" onClick={onReset}>Сбросить фильтры</Button>}
</Panel>
);
}

View File

@ -1,4 +1,4 @@
import { render, screen, waitFor, within } from "@testing-library/react";
import { cleanup, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { createMemoryRouter } from "react-router";
import { beforeEach, describe, expect, it } from "vitest";
@ -123,16 +123,6 @@ describe("«Бэклог»", () => {
expect(within(det).getAllByText("не выполнено")).toHaveLength(12);
});
it("только смотрит: нет draggable, кнопки — только summary и «Обновить из Jira»", async () => {
hub();
open("/app/jira/backlog?project=AWPCA");
const p = await loaded();
expect(document.querySelectorAll('[draggable="true"]').length).toBe(0);
const buttons = within(p).queryAllByRole("button");
expect(buttons.length).toBeGreaterThan(0);
expect(buttons.every((b) => b.tagName === "SUMMARY" || /Обновить из Jira/.test(b.textContent ?? ""))).toBe(true);
});
it("щелчок по строке открывает карточку поверх «Бэклога»", async () => {
hub();
const router = open("/app/jira/backlog?project=AWPCA");
@ -187,4 +177,233 @@ describe("«Бэклог»", () => {
expect(await screen.findByText("У проекта нет scrum-доски")).toBeInTheDocument();
expect(router.state.location.pathname).toBe("/app/jira/backlog");
});
it("свёртка: заголовок — кнопка aria-expanded, итоги видны, состояние помнится; сворачиваются и будущий, и «Бэклог»", async () => {
hub();
open("/app/jira/backlog?project=AWPCA");
let p = within(await loaded());
const toggle = (name: string) => p.getByRole("button", { name: new RegExp(`^${name}`) });
expect(toggle("AWPCA_Sprint_3")).toHaveAttribute("aria-expanded", "true");
await userEvent.click(toggle("AWPCA_Sprint_3"));
expect(toggle("AWPCA_Sprint_3")).toHaveAttribute("aria-expanded", "false");
const active = p.getByRole("region", { name: /AWPCA_Sprint_3/ });
expect(active).not.toHaveTextContent("Тема AURORA-1");
expect(active).toHaveTextContent("12 задач");
expect(active).toHaveTextContent("25 SP (выполнено 8)");
await userEvent.click(toggle("AWPCA_Sprint_4"));
await userEvent.click(toggle("Бэклог"));
expect(p.getByRole("region", { name: /Бэклог/ })).not.toHaveTextContent("AURORA-50");
expect(p.getByRole("region", { name: /Бэклог/ })).toHaveTextContent("2 задачи");
cleanup();
open("/app/jira/backlog?project=AWPCA");
p = within(await loaded());
expect(toggle("AWPCA_Sprint_3")).toHaveAttribute("aria-expanded", "false");
expect(toggle("Бэклог")).toHaveAttribute("aria-expanded", "false");
await userEvent.click(toggle("AWPCA_Sprint_3"));
expect(toggle("AWPCA_Sprint_3")).toHaveAttribute("aria-expanded", "true");
expect(p.getByRole("region", { name: /AWPCA_Sprint_3/ })).toHaveTextContent("Тема AURORA-1");
});
it("эпики строками не показаны и не входят в «N задач» бэклога", async () => {
hub({ data: agile({
sprints: [sprint({ id: 102, issues: [iss("AURORA-1"), iss("AURORA-900", { issue_type: "Фича" })] })],
backlog: [iss("AURORA-50"), iss("AURORA-901", { issue_type: "Фича" })],
}) });
open("/app/jira/backlog?project=AWPCA");
const p = within(await loaded());
const rowTexts = [...document.querySelectorAll(".agile-row")].map((r) => r.textContent);
expect(rowTexts.some((t) => t?.includes("AURORA-900") || t?.includes("AURORA-901"))).toBe(false); // в панели «Эпики» они есть, строками — нет
expect(rowTexts.some((t) => t?.includes("AURORA-50"))).toBe(true);
expect(p.getByRole("region", { name: /Бэклог/ })).toHaveTextContent("1 задача");
});
it("«Скрыть выполненные»: 6 из 10, «показано 6»; прошлый спринт раскрыт целиком", async () => {
hub({ data: agile({
sprints: [
sprint({ id: 102, name: "AWPCA_Sprint_3", count: 10, count_done: 4, issues: many(10, 4) }),
sprint({ id: 101, name: "Old", state: "closed", complete_date: COMPLETE, count: 10, count_done: 4, issues: many(10, 4).map((i) => ({ ...i, key: `OLD-${i.key}` })) }),
],
}) });
open("/app/jira/backlog?project=AWPCA");
const p = within(await loaded());
await userEvent.click(p.getByLabelText("Скрыть выполненные"));
const sec = p.getByRole("region", { name: /AWPCA_Sprint_3/ });
expect(sec.querySelectorAll(".agile-row")).toHaveLength(6);
expect(sec).toHaveTextContent("10 задач");
expect(sec).toHaveTextContent("показано 6");
await userEvent.click(p.getByText(/^Old · закрыт/));
expect(document.querySelectorAll("details .agile-row")).toHaveLength(10);
});
it("поиск «AURORA-50»: в «Бэклоге» одна строка, в спринте «Нет задач по фильтрам»", async () => {
hub();
open("/app/jira/backlog?project=AWPCA");
const p = within(await loaded());
await userEvent.type(p.getByLabelText("Поиск по ключу или теме"), "AURORA-50");
expect(p.getByRole("region", { name: /Бэклог/ }).querySelectorAll(".agile-row")).toHaveLength(1);
expect(p.getByRole("region", { name: /AWPCA_Sprint_3/ })).toHaveTextContent("Нет задач по фильтрам");
});
it("смена проекта: исполнитель другого проекта не протекает", async () => {
const B = { ...A, label: "SECOND", name: "Второй", board_id: 5 };
mockFetch((method, url) => {
const k = `${method} ${url.pathname}`;
if (k === "GET /agile") return json(200, agile());
if (k === "GET /projects") return json(200, { projects: [A, B] });
if (k === "GET /settings") return json(200, settings);
return json(200, { issues: [], batches: [], agents: [] });
});
open("/app/jira/backlog?project=AWPCA");
await loaded();
await userEvent.selectOptions(screen.getByLabelText("Исполнитель"), "Иванов Иван");
expect(screen.getByLabelText("Исполнитель")).toHaveValue("Иванов Иван");
await userEvent.selectOptions(screen.getByLabelText("Проект"), "SECOND");
await waitFor(() => expect(screen.getByLabelText("Исполнитель")).toHaveValue(""));
});
it("бэклог только смотрит: нет draggable, кнопки — свёртка, панель фильтров, раскрытие", async () => {
hub();
open("/app/jira/backlog?project=AWPCA");
const p = await loaded();
expect(document.querySelectorAll("[draggable]").length).toBe(0);
const names = within(p).getAllByRole("button").map((b) => b.tagName === "SUMMARY" ? "summary" : (b.textContent ?? "").trim());
expect(names.length).toBeGreaterThan(0);
for (const n of names) {
expect(n === "summary" || /^(AWPCA_Sprint_3|AWPCA_Sprint_4|Бэклог|Обновить из Jira|Сбросить фильтры|Эпики|Все задачи|Без эпика)/.test(n)).toBe(true);
}
});
describe("панель «Эпики»", () => {
const epicRow = (key: string, over: Partial<AgileIssue> = {}) => iss(key, { issue_type: "Фича", summary: `Эпик ${key}`, ...over });
const inEpic = (key: string, epic: string | null) => iss(key, { epic_key: epic });
const epicData = (over: Partial<AgileResponse> = {}) => agile({
sprints: [sprint({ id: 102, name: "AWPCA_Sprint_3", count: 3, issues: [epicRow("AURORA-10"), inEpic("AURORA-1", "AURORA-10"), inEpic("AURORA-2", "AURORA-10"), inEpic("AURORA-3", null)] }),
sprint({ id: 101, name: "Old", state: "closed", complete_date: COMPLETE, count: 2, count_done: 1, issues: [inEpic("OLD-1", "AURORA-10"), inEpic("OLD-2", null)] })],
backlog: [inEpic("AURORA-50", "AURORA-10"), inEpic("AURORA-51", "AURORA-10"), inEpic("AURORA-52", "AURORA-10"),
...[60, 61, 62, 63, 64].map((n) => inEpic(`AURORA-${n}`, null))],
...over,
});
const panel = () => within(screen.getByRole("complementary", { name: "Эпики" }));
const item = (name: RegExp | string) => panel().getByRole("button", { name });
const rows = (region: HTMLElement) => region.querySelectorAll(".agile-row");
it("«Фильтр по эпику»: 2 в спринте и «показано 2», 3 в «Бэклоге», строки эпика нет, у пункта 5", async () => {
hub({ data: epicData() });
open("/app/jira/backlog?project=AWPCA");
const p = within(await loaded());
expect(item(/AURORA-10/)).toHaveTextContent("5");
await userEvent.click(item(/AURORA-10/));
const sprintSec = p.getByRole("region", { name: /AWPCA_Sprint_3/ });
expect(rows(sprintSec)).toHaveLength(2);
expect(sprintSec).toHaveTextContent("показано 2");
expect(rows(p.getByRole("region", { name: /Бэклог/ }))).toHaveLength(3);
expect(p.queryByText("Эпик AURORA-10", { selector: ".agile-row *" })).toBeNull();
expect(item(/AURORA-10/)).toHaveTextContent("5");
expect(item(/AURORA-10/)).toHaveAttribute("aria-pressed", "true");
});
it("повторный щелчок по выбранному пункту — «Все задачи»", async () => {
hub({ data: epicData() });
open("/app/jira/backlog?project=AWPCA");
await loaded();
expect(item("Все задачи")).toHaveAttribute("aria-pressed", "true");
await userEvent.click(item(/AURORA-10/));
expect(item("Все задачи")).toHaveAttribute("aria-pressed", "false");
await userEvent.click(item(/AURORA-10/));
expect(item("Все задачи")).toHaveAttribute("aria-pressed", "true");
expect(item(/AURORA-10/)).toHaveAttribute("aria-pressed", "false");
});
it("«Без эпика» — только задачи без epic_key", async () => {
hub({ data: epicData() });
open("/app/jira/backlog?project=AWPCA");
const p = within(await loaded());
await userEvent.click(item("Без эпика"));
expect(rows(p.getByRole("region", { name: /Бэклог/ }))).toHaveLength(5);
expect(rows(p.getByRole("region", { name: /AWPCA_Sprint_3/ }))).toHaveLength(1);
});
it("эпик без строки — пункт с ключом, фильтр по нему работает", async () => {
hub({ data: agile({ sprints: [], backlog: [inEpic("AURORA-50", "AURORA-77"), inEpic("AURORA-51", null)] }) });
open("/app/jira/backlog?project=AWPCA");
const p = within(await loaded());
await userEvent.click(item(/AURORA-77/));
expect(rows(p.getByRole("region", { name: /Бэклог/ }))).toHaveLength(1);
expect(p.getByText("Тема AURORA-50")).toBeInTheDocument();
});
it("«Скрыть выполненные»: выполненный эпик пропадает из панели, выбранный остаётся", async () => {
hub({ data: agile({ sprints: [], backlog: [epicRow("AURORA-10", { done: true }), epicRow("AURORA-11"), inEpic("AURORA-50", "AURORA-10")] }) });
open("/app/jira/backlog?project=AWPCA");
const p = within(await loaded());
await userEvent.click(p.getByLabelText("Скрыть выполненные"));
expect(panel().queryByRole("button", { name: /AURORA-10/ })).toBeNull();
expect(item(/AURORA-11/)).toBeInTheDocument();
await userEvent.click(p.getByLabelText("Скрыть выполненные"));
await userEvent.click(item(/AURORA-10/));
await userEvent.click(p.getByLabelText("Скрыть выполненные"));
expect(item(/AURORA-10/)).toHaveAttribute("aria-pressed", "true");
});
it("сохранённый эпик, которого нет в данных, остаётся выбранным пунктом; секции — «Нет задач по фильтрам»", async () => {
localStorage.setItem("sdlc-hub-agile", JSON.stringify({ AWPCA: { backlog: { epic: "AURORA-999" } } }));
hub({ data: epicData() });
open("/app/jira/backlog?project=AWPCA");
const p = within(await loaded());
expect(item(/AURORA-999/)).toHaveAttribute("aria-pressed", "true");
expect(p.getByRole("region", { name: /Бэклог/ })).toHaveTextContent("Нет задач по фильтрам");
expect(p.getByRole("region", { name: /AWPCA_Sprint_3/ })).toHaveTextContent("Нет задач по фильтрам");
});
it("кнопка «Эпики» скрывает и показывает панель, состояние переживает повторный монтаж", async () => {
hub({ data: epicData() });
open("/app/jira/backlog?project=AWPCA");
const p = within(await loaded());
const btn = () => p.getByRole("button", { name: "Эпики" });
expect(btn()).toHaveAttribute("aria-pressed", "true");
await userEvent.click(btn());
expect(btn()).toHaveAttribute("aria-pressed", "false");
expect(screen.queryByRole("complementary", { name: "Эпики" })).toBeNull();
cleanup();
open("/app/jira/backlog?project=AWPCA");
const p2 = within(await loaded());
expect(p2.getByRole("button", { name: "Эпики" })).toHaveAttribute("aria-pressed", "false");
expect(screen.queryByRole("complementary", { name: "Эпики" })).toBeNull();
await userEvent.click(p2.getByRole("button", { name: "Эпики" }));
expect(screen.getByRole("complementary", { name: "Эпики" })).toBeInTheDocument();
});
it("M-3: панель «Эпики» скрыта при выбранном эпике — выбор «Эпик» показан, значение видно и снимается", async () => {
localStorage.setItem("sdlc-hub-agile", JSON.stringify({ AWPCA: { backlog: { epic: "AURORA-10", epicsPanel: false } } }));
hub({ data: epicData() });
open("/app/jira/backlog?project=AWPCA");
const p = within(await loaded());
const sel = p.getByLabelText("Эпик");
expect(sel.closest(".agile-epic-select")).toHaveClass("is-forced");
expect(sel).toHaveValue("AURORA-10");
await userEvent.selectOptions(sel, "");
expect(sel).toHaveValue("");
});
it("выбор «Эпик» в панели фильтров (узкое окно) ставит тот же фильтр", async () => {
hub({ data: epicData() });
open("/app/jira/backlog?project=AWPCA");
const p = within(await loaded());
await userEvent.selectOptions(p.getByLabelText("Эпик"), "AURORA-10");
expect(item(/AURORA-10/)).toHaveAttribute("aria-pressed", "true");
expect(rows(p.getByRole("region", { name: /Бэклог/ }))).toHaveLength(3);
expect([...p.getByLabelText("Эпик").querySelectorAll("option")].map((o) => o.textContent)).toEqual(["Все задачи", "Эпик AURORA-10", "Без эпика"]);
});
it("фильтр эпика применяется и к прошлому спринту", async () => {
hub({ data: epicData() });
open("/app/jira/backlog?project=AWPCA");
const p = within(await loaded());
await userEvent.click(item(/AURORA-10/));
const summary = p.getByText(/^Old · закрыт/);
expect(summary).toHaveTextContent("показано 1");
await userEvent.click(summary);
expect(summary.closest("details")!.querySelectorAll(".agile-row")).toHaveLength(1);
});
});
});

View File

@ -1,41 +1,75 @@
import { useId } from "react";
import type { Project } from "../../../api/types.ts";
import { Badge, PageHeader, Panel } from "../../../ui/index.ts";
import { Badge, Button, PageHeader, Panel } from "../../../ui/index.ts";
import { IconChevronDown, IconChevronUp } from "../../../ui/icons.tsx";
import type { AgileIssue, AgileSprint } from "../api/types.ts";
import { RefreshButton } from "../RefreshButton.tsx";
import { AgileGate } from "./AgileGate.tsx";
import { AgileToolbar } from "./AgileToolbar.tsx";
import { EpicSelect, EpicsPanel } from "./EpicsPanel.tsx";
import { assigneeOptions, epicEntries, matches, type IssueFilter } from "./filters.ts";
import { useAgileView } from "./useAgileView.ts";
import { AgileIssueRow } from "./IssueRow.tsx";
import { fmtDay, fmtRange, fmtSp, tasksWord } from "./model.ts";
import { fmtDay, fmtRange, fmtSp, isEpic, tasksWord } from "./model.ts";
import "./agile.css";
function Issues({ issues, markUndone }: { issues: AgileIssue[]; markUndone?: boolean }) {
if (!issues.length) return <p className="agile__empty">Нет задач</p>;
// задачи страницы: без строк-эпиков, подходящие под фильтр
const visible = (issues: AgileIssue[], filter: IssueFilter) => issues.filter((i) => !isEpic(i) && matches(i, filter));
function Issues({ issues, total, markUndone }: { issues: AgileIssue[]; total: number; markUndone?: boolean }) {
if (!issues.length) return <p className="agile__empty">{total > 0 ? "Нет задач по фильтрам" : "Нет задач"}</p>;
return <ul className="agile-list">{issues.map((i) => <AgileIssueRow key={i.key} issue={i} mark={markUndone && !i.done ? "не выполнено" : undefined} />)}</ul>;
}
function SprintSection({ s }: { s: AgileSprint }) {
// заголовок секции — кнопка свёртки; id — String(sprint.id) или "backlog"
function Toggle({ titleId, bodyId, collapsed, onToggle, children }: {
titleId: string; bodyId: string; collapsed: boolean; onToggle(): void; children: string;
}) {
return (
<h2 id={titleId} className="agile-section__title">
<button type="button" className="agile-section__toggle" aria-expanded={!collapsed} aria-controls={bodyId} onClick={onToggle}>
{children}
{collapsed ? <IconChevronDown /> : <IconChevronUp />}
</button>
</h2>
);
}
interface SectionProps { collapsed: boolean; onToggle(): void; shown: AgileIssue[] }
function Shown({ shown, total }: { shown: AgileIssue[]; total: number }) {
return shown.length < total ? <span className="agile-section__meta">{`показано ${shown.length}`}</span> : null;
}
function SprintSection({ s, collapsed, onToggle, shown }: { s: AgileSprint } & SectionProps) {
const id = useId();
const bodyId = `${id}-body`;
const total = s.issues.filter((i) => !isEpic(i)).length;
return (
<Panel as="section" className="agile-section" aria-labelledby={id}>
<header className="agile-section__head">
<h2 id={id} className="agile-section__title">{s.name}</h2>
<Toggle titleId={id} bodyId={bodyId} collapsed={collapsed} onToggle={onToggle}>{s.name}</Toggle>
<Badge>{s.state === "active" ? "активный" : "будущий"}</Badge>
<span className="agile-section__meta">{fmtRange(s.start_date, s.end_date)}</span>
{s.goal && <span className="agile-section__goal">{s.goal}</span>}
<span className="agile-section__meta">{`${s.count} ${tasksWord(s.count)}`}</span>
<Shown shown={shown} total={total} />
<span className="agile-section__meta">{`${fmtSp(s.points)} SP (выполнено ${fmtSp(s.points_done)})`}</span>
{s.unestimated > 0 && <span className="agile-section__meta">{`без оценки: ${s.unestimated}`}</span>}
</header>
<Issues issues={s.issues} />
<div id={bodyId} hidden={collapsed}>{!collapsed && <Issues issues={shown} total={total} />}</div>
</Panel>
);
}
function PastSprint({ s }: { s: AgileSprint }) {
function PastSprint({ s, filter }: { s: AgileSprint; filter: IssueFilter }) {
const all = s.issues.filter((i) => !isEpic(i));
const shown = visible(all, { ...filter, hideDone: false });
const more = shown.length < all.length ? ` · показано ${shown.length}` : "";
return (
<details className="agile-past__sprint">
<summary>{`${s.name} · закрыт ${fmtDay(s.complete_date)} · выполнено ${s.count_done} из ${s.count} · ${fmtSp(s.points)} SP`}</summary>
<Issues issues={s.issues} markUndone />
<summary>{`${s.name} · закрыт ${fmtDay(s.complete_date)} · выполнено ${s.count_done} из ${s.count} · ${fmtSp(s.points)} SP${more}`}</summary>
<Issues issues={shown} total={all.length} markUndone />
</details>
);
}
@ -43,6 +77,9 @@ function PastSprint({ s }: { s: AgileSprint }) {
// «Бэклог» (spec web-app): активные и будущие спринты, бэклог, прошлые спринты — только просмотр (R-20)
export function BacklogPage({ project }: { project: Project }) {
const backlogId = useId();
const { view, set, setQ, filter, reset } = useAgileView(project.label, "backlog");
const isCollapsed = (id: string) => view.collapsed.includes(id);
const toggle = (id: string) => set({ collapsed: isCollapsed(id) ? view.collapsed.filter((x) => x !== id) : [...view.collapsed, id] });
return (
<div data-testid="page-backlog" className="agile">
<PageHeader title="Бэклог" actions={<RefreshButton />} />
@ -50,22 +87,41 @@ export function BacklogPage({ project }: { project: Project }) {
{(d) => {
const live = [...d.sprints.filter((s) => s.state === "active"), ...d.sprints.filter((s) => s.state === "future")];
const past = d.sprints.filter((s) => s.state === "closed");
const backlog = (d.backlog ?? []).filter((i) => !isEpic(i));
const shownBacklog = visible(backlog, filter);
const epicProps = { entries: epicEntries(d), value: view.epic, onChange: (epic: string) => set({ epic }), hideDone: view.hideDone };
const allIssues = [...d.sprints.flatMap((s) => s.issues), ...(d.backlog ?? [])].filter((i) => !isEpic(i));
return (
<>
{live.map((s) => <SprintSection key={s.id} s={s} />)}
<AgileToolbar filter={filter} setQ={setQ} set={set} onReset={reset} assignees={assigneeOptions(allIssues, view.assignee)} showHideDone>
<EpicSelect {...epicProps} forced={!view.epicsPanel} />
<Button className="agile-epics-toggle" aria-pressed={view.epicsPanel} onClick={() => set({ epicsPanel: !view.epicsPanel })}>Эпики</Button>
</AgileToolbar>
<div className={`agile-backlog${view.epicsPanel ? " has-epics" : ""}`}>
{view.epicsPanel && <EpicsPanel {...epicProps} />}
<div className="agile-backlog__main">
{live.map((s) => (
<SprintSection key={s.id} s={s} collapsed={isCollapsed(String(s.id))} onToggle={() => toggle(String(s.id))}
shown={visible(s.issues, filter)} />
))}
<Panel as="section" className="agile-section" aria-labelledby={backlogId}>
<header className="agile-section__head">
<h2 id={backlogId} className="agile-section__title">Бэклог</h2>
<span className="agile-section__meta">{`${d.backlog?.length ?? 0} ${tasksWord(d.backlog?.length ?? 0)}`}</span>
<Toggle titleId={backlogId} bodyId={`${backlogId}-body`} collapsed={isCollapsed("backlog")} onToggle={() => toggle("backlog")}>Бэклог</Toggle>
<span className="agile-section__meta">{`${backlog.length} ${tasksWord(backlog.length)}`}</span>
<Shown shown={shownBacklog} total={backlog.length} />
</header>
<Issues issues={d.backlog ?? []} />
<div id={`${backlogId}-body`} hidden={isCollapsed("backlog")}>
{!isCollapsed("backlog") && <Issues issues={shownBacklog} total={backlog.length} />}
</div>
</Panel>
{past.length > 0 && (
<details className="agile-past">
<summary>{`Прошлые спринты (${past.length})`}</summary>
{past.map((s) => <PastSprint key={s.id} s={s} />)}
{past.map((s) => <PastSprint key={s.id} s={s} filter={filter} />)}
</details>
)}
</div>
</div>
</>
);
}}

View File

@ -1,15 +1,16 @@
import type { ColumnView } from "./columns.ts";
import { effectiveHidden, type ColumnView } from "./columns.ts";
import { AgileCard } from "./AgileCard.tsx";
// колонки доски; контейнер прокручивается по горизонтали внутри себя, страница не растягивается
export function BoardColumns({ columns, lastCaption }: { columns: ColumnView[]; lastCaption?: string }) {
// колонки доски; контейнер прокручивается по горизонтали внутри себя; колонки одной высоты, у каждой прокручивается список
export function BoardColumns({ columns, lastCaption, hidden = [] }: { columns: ColumnView[]; lastCaption?: string; hidden?: string[] }) {
const lastIdx = columns.reduce((acc, c, i) => (c.extra ? acc : i), -1);
const gone = effectiveHidden(columns.map((c) => c.name), hidden);
return (
<div className="agile-columns">
{columns.map((c, i) => (
{columns.map((c, i) => gone.includes(c.name) ? null : (
<section key={c.name + i} className={"agile-col" + (c.extra ? " is-extra" : "")} aria-label={c.name}>
<header className="agile-col__head">
<h2 className="agile-col__name">{c.name}</h2>
<h3 className="agile-col__name">{c.name}</h3>
<span className="agile-col__count">{c.issues.length}</span>
</header>
{lastCaption && i === lastIdx && <p className="agile-col__caption">{lastCaption}</p>}

View File

@ -0,0 +1,26 @@
// меню «Колонки» экранов «Спринт» и «Kanban»: скрыть колонки доски; последнюю видимую скрыть нельзя
import { Badge, Checkbox, Dropdown, IconChevronDown } from "../../../ui/index.ts";
import { effectiveHidden } from "./columns.ts";
export function ColumnsMenu({ names, hidden, onChange }: { names: string[]; hidden: string[]; onChange(hidden: string[]): void }) {
const gone = effectiveHidden(names, hidden); // имена, которых на доске больше нет, не показываются и не считаются
const visible = names.filter((n) => !gone.includes(n));
const flip = (name: string, show: boolean) => onChange(names.filter((n) => (n === name ? !show : gone.includes(n))));
return (
<Dropdown className="agile-columns-menu" button={({ open, toggle }) => (
<button type="button" className="ui-input multi-btn" aria-label={gone.length > 0 ? `Колонки, скрыто ${gone.length}` : "Колонки"} aria-haspopup="true" aria-expanded={open} onClick={toggle}>
<span className="lbl">Колонки</span>{gone.length > 0 && <Badge>{gone.length}</Badge>}<IconChevronDown size={16} />
</button>
)}>
{() => (
<div className="dd-list" role="group" aria-label="Колонки">
{names.map((n) => {
const on = !gone.includes(n);
return <Checkbox key={n} className="dd-item" label={n} checked={on} disabled={on && visible.length === 1}
onChange={(e) => flip(n, e.target.checked)} />;
})}
</div>
)}
</Dropdown>
);
}

View File

@ -0,0 +1,49 @@
// панель «Эпики» бэклога и её узкий вариант — выбор «Эпик» (spec web-app); оба только меняют фильтр, Jira не трогают
import { Select } from "../../../ui/index.ts";
import { NONE, type EpicEntry } from "./filters.ts";
interface Props { entries: EpicEntry[]; value: string; onChange(v: string): void; hideDone: boolean }
// выполненные скрываются при «Скрыть выполненные», кроме выбранного; выбранный, которого нет в данных, остаётся пунктом по ключу
function listed({ entries, value, hideDone }: Pick<Props, "entries" | "value" | "hideDone">): EpicEntry[] {
const list = entries.filter((e) => !(hideDone && e.done && e.key !== value));
if (value && value !== NONE && !entries.some((e) => e.key === value)) list.push({ key: value, title: value, count: 0, done: false });
return list;
}
export function EpicsPanel(props: Props) {
const { value, onChange } = props;
const item = (v: string, title: string, key?: string, count?: number) => (
<li key={v}>
<button type="button" className="agile-epics__item" aria-pressed={value === v} onClick={() => onChange(value === v ? "" : v)}>
<span className="agile-epics__title">{title}</span>
{key && key !== title && <span className="agile-epics__key">{key}</span>}
{count !== undefined && <span className="agile-epics__count">{count}</span>}
</button>
</li>
);
return (
<aside className="agile-epics" aria-label="Эпики">
<ul className="agile-epics__list">
<li>
<button type="button" className="agile-epics__item" aria-pressed={value === ""} onClick={() => onChange("")}>
<span className="agile-epics__title">Все задачи</span>
</button>
</li>
{listed(props).map((e) => item(e.key, e.title, e.key, e.count))}
{item(NONE, "Без эпика")}
</ul>
</aside>
);
}
// forced — панель «Эпики» скрыта, а выбранный эпик должен оставаться виден и снимаем (M-3)
export function EpicSelect(props: Props & { forced?: boolean }) {
return (
<Select aria-label="Эпик" className={`agile-epic-select${props.forced ? " is-forced" : ""}`} value={props.value} onChange={(e) => props.onChange(e.target.value)}>
<option value="">Все задачи</option>
{listed(props).map((e) => <option key={e.key} value={e.key}>{e.title}</option>)}
<option value={NONE}>Без эпика</option>
</Select>
);
}

View File

@ -1,4 +1,5 @@
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { createMemoryRouter } from "react-router";
import { beforeEach, describe, expect, it } from "vitest";
import { App } from "../../../App.tsx";
@ -110,4 +111,48 @@ describe("«Kanban»", () => {
expect(p.getByRole("link", { name: "Связи" })).toHaveAttribute("href", "/app/jira/links?project=AWPCA");
expect(urls).not.toContain("GET /agile");
});
it("поиск и исполнитель фильтруют колонки и «Бэклог (N)»; флажка «Скрыть выполненные» нет, меню «Колонки» есть", async () => {
const d = agile(true);
d.backlog = [iss("AURORA-1", { assignee: "Иванов" }), iss("AURORA-2")];
d.board_issues = [iss("AURORA-3", { status: "В работе", assignee: "Иванов" }), iss("AURORA-4", { status: "Готово", done: true })];
hub(d);
open("/app/jira/kanban?project=KAN");
const p = await page();
await within(p).findByText("Бэклог (2)");
expect(within(p).queryByRole("checkbox", { name: "Скрыть выполненные" })).toBeNull();
expect(within(p).getByRole("button", { name: /^Колонки/ })).toBeInTheDocument();
await userEvent.selectOptions(within(p).getByLabelText("Исполнитель"), "Иванов");
expect(within(p).getByText("Бэклог (1)")).toBeInTheDocument();
expect(within(p).queryByText("Тема AURORA-4")).toBeNull();
expect(within(p).getByText("Тема AURORA-3")).toBeInTheDocument();
await userEvent.selectOptions(within(p).getByLabelText("Исполнитель"), "");
await userEvent.type(within(p).getByRole("searchbox"), "AURORA-4");
expect(within(p).getByText("Бэклог (0)")).toBeInTheDocument();
expect(within(p).getByText("Тема AURORA-4")).toBeInTheDocument();
expect(within(p).queryByText("Тема AURORA-3")).toBeNull();
});
it("hideDone=true в хранилище на «Kanban» не применяется: выполненные остаются", async () => {
localStorage.setItem("sdlc-hub-agile", JSON.stringify({ KAN: { kanban: { hideDone: true } } }));
hub(agile(false));
open("/app/jira/kanban?project=KAN");
const p = await page();
expect(await within(p).findByText("Тема AURORA-4")).toBeInTheDocument();
expect(within(p).queryByRole("button", { name: "Сбросить фильтры" })).toBeNull();
});
it("скрытая колонка: снять «Готово» — колонки нет; имя колонки, которой нет, в хранилище не ломает меню", async () => {
localStorage.setItem("sdlc-hub-agile", JSON.stringify({ KAN: { kanban: { hiddenColumns: ["Старая"] } } }));
hub(agile(false));
open("/app/jira/kanban?project=KAN");
const p = await page();
await within(p).findByText("Тема AURORA-3");
const btn = within(p).getByRole("button", { name: /^Колонки/ });
expect(btn).toHaveTextContent(/^Колонки$/);
await userEvent.click(btn);
await userEvent.click(within(screen.getByRole("group", { name: "Колонки" })).getByRole("checkbox", { name: "Готово" }));
expect(colNames(p)).toEqual(["Бэклог", "В работе", "Без колонки"]);
expect(btn).toHaveTextContent("Колонки1");
});
});

View File

@ -2,13 +2,20 @@ import type { Project } from "../../../api/types.ts";
import { PageHeader } from "../../../ui/index.ts";
import { RefreshButton } from "../RefreshButton.tsx";
import { AgileGate } from "./AgileGate.tsx";
import { AgileToolbar } from "./AgileToolbar.tsx";
import { BoardColumns } from "./BoardColumns.tsx";
import { ColumnsMenu } from "./ColumnsMenu.tsx";
import { columnsOf } from "./columns.ts";
import { assigneeOptions, matches } from "./filters.ts";
import { AgileIssueRow } from "./IssueRow.tsx";
import { useAgileView } from "./useAgileView.ts";
import "./agile.css";
// «Kanban» (spec web-app): колонки доски; при включённом бэклоге Jira первая колонка не рисуется, её задачи — в «Бэклог (N)» (R-18)
// «Kanban» (spec web-app): колонки доски; при включённом бэклоге Jira первая колонка не рисуется, её задачи — в «Бэклог (N)» (R-18).
// «Скрыть выполненные» здесь нет: даже сохранённое hideDone не применяется (последняя колонка и так ограничена «за 14 дней»)
export function KanbanPage({ project }: { project: Project }) {
const { view, set, setQ, filter, reset } = useAgileView(project.label, "kanban");
const kfilter = { ...filter, hideDone: false };
return (
<div data-testid="page-kanban" className="agile agile--wide">
<PageHeader title="Kanban" actions={<RefreshButton />} />
@ -16,17 +23,24 @@ export function KanbanPage({ project }: { project: Project }) {
{(d) => {
const withBacklog = d.board?.kanban_backlog === true;
const cols = d.board?.columns ?? null;
const issues = d.board_issues ?? []; // сервер уже убрал задачи первой колонки, если бэклог включён
const backlog = d.backlog ?? [];
const issues = (d.board_issues ?? []).filter((i) => matches(i, kfilter)); // сервер уже убрал задачи первой колонки, если бэклог включён
const backlog = (d.backlog ?? []).filter((i) => matches(i, kfilter));
const boardCols = withBacklog && cols ? cols.slice(1) : cols;
const views = columnsOf(boardCols, issues);
const allNames = columnsOf(boardCols, d.board_issues ?? []).map((c) => c.name); // меню — по задачам без фильтров (T7.2)
return (
<>
<AgileToolbar filter={kfilter} setQ={setQ} set={set} onReset={reset} showHideDone={false}
assignees={assigneeOptions([...(d.board_issues ?? []), ...(d.backlog ?? [])], view.assignee)}>
<ColumnsMenu names={allNames} hidden={view.hiddenColumns} onChange={(hiddenColumns) => set({ hiddenColumns })} />
</AgileToolbar>
{withBacklog && (
<details className="agile-past">
<summary>{`Бэклог (${backlog.length})`}</summary>
<ul className="agile-list">{backlog.map((i) => <AgileIssueRow key={i.key} issue={i} />)}</ul>
</details>
)}
<BoardColumns columns={columnsOf(withBacklog && cols ? cols.slice(1) : cols, issues)} lastCaption="за 14 дней" />
<BoardColumns columns={views} lastCaption="за 14 дней" hidden={view.hiddenColumns} />
</>
);
}}

View File

@ -1,4 +1,4 @@
import { render, screen, waitFor, within } from "@testing-library/react";
import { cleanup, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { createMemoryRouter } from "react-router";
import { beforeEach, describe, expect, it } from "vitest";
@ -96,46 +96,221 @@ describe("«Спринт»", () => {
expect(await within(await page()).findByText("срок прошёл")).toBeInTheDocument();
});
it("два активных: переключатель «Спринты», выбор — ?sprint=<id> и его задачи", async () => {
hub(agile([s102(), sprint({ id: 104, name: "AWPCA_Sprint_5", issues: [iss("AURORA-77")], count: 1 })]));
const router = open("/app/jira/sprint?project=AWPCA");
const p = within(await page());
await p.findByText("Тема AURORA-1");
const seg = p.getByRole("navigation", { name: "Спринты" });
await userEvent.click(within(seg).getByRole("link", { name: "AWPCA_Sprint_5" }));
expect(await p.findByText("Тема AURORA-77")).toBeInTheDocument();
expect(p.queryByText("Тема AURORA-1")).toBeNull();
expect(router.state.location.search).toContain("sprint=104");
expect(router.state.location.search).toContain("project=AWPCA");
const s104 = () => sprint({ id: 104, name: "AWPCA_Sprint_5", issues: [iss("AURORA-77")], count: 1 });
const s103 = () => sprint({ id: 103, name: "AWPCA_Sprint_4", state: "future", start_date: iso(5), end_date: iso(15), issues: [iss("AURORA-88")], count: 1 });
const picker = (p: HTMLElement) => p.querySelector<HTMLButtonElement>("button[aria-haspopup]")!;
const sprintGroup = () => screen.getByRole("group", { name: "Спринты" });
it("по умолчанию выбраны все активные: два активных — два заголовка и задачи обоих", async () => {
hub(agile([s102(), s104()]));
open("/app/jira/sprint?project=AWPCA");
const p = await page();
await within(p).findByText("Тема AURORA-1");
expect(within(p).getByText("Тема AURORA-77")).toBeInTheDocument();
expect(within(p).getAllByRole("heading", { level: 2 }).map((h) => h.textContent)).toEqual(
expect.arrayContaining(["AWPCA_Sprint_3", "AWPCA_Sprint_5"]));
expect(picker(p)).toHaveTextContent("Спринты: 2");
});
it("один активный: переключателя нет", async () => {
it("один активный: на кнопке «Спринты» его имя", async () => {
hub(agile());
open("/app/jira/sprint?project=AWPCA");
const p = within(await page());
await p.findByText("Тема AURORA-1");
expect(p.queryByRole("navigation", { name: "Спринты" })).toBeNull();
const p = await page();
await within(p).findByText("Тема AURORA-1");
expect(picker(p)).toHaveTextContent("AWPCA_Sprint_3");
});
it("устаревший ?sprint= — первый активный", async () => {
it("«Будущий спринт на доске»: отметить будущий — ?sprint=101,102 через replace, «осталось» только у активного", async () => {
hub(agile([sprint({ id: 101, name: "Активный", issues: [iss("AURORA-5")], count: 1 }), sprint({ id: 102, name: "Будущий", state: "future", start_date: iso(5), end_date: iso(15), issues: [iss("AURORA-6")], count: 1 })]));
const router = open("/app/jira/sprint?project=AWPCA");
const p = await page();
await within(p).findByText("Тема AURORA-5");
expect(within(p).queryByText("Тема AURORA-6")).toBeNull();
await userEvent.click(picker(p));
await userEvent.click(within(sprintGroup()).getByRole("checkbox", { name: /Будущий/ }));
expect(await within(p).findByText("Тема AURORA-6")).toBeInTheDocument();
expect(router.state.location.search).toContain("sprint=101%2C102");
expect(router.state.location.search).toContain("project=AWPCA");
expect(router.state.historyAction).toBe("REPLACE");
expect(within(p).getAllByRole("heading", { level: 2, name: /^(Активный|Будущий)$/ })).toHaveLength(2);
expect(within(p).getAllByText(/осталось \d+ дн\./)).toHaveLength(1);
});
it("в списке спринтов подписи «активный»/«будущий»; последнюю отметку снять нельзя", async () => {
hub(agile([s102(), s103()]));
open("/app/jira/sprint?project=AWPCA");
const p = await page();
await within(p).findByText("Тема AURORA-1");
await userEvent.click(picker(p));
const g = within(sprintGroup());
expect(g.getByRole("checkbox", { name: /AWPCA_Sprint_3.*активный/ })).toBeChecked();
expect(g.getByRole("checkbox", { name: /AWPCA_Sprint_4.*будущий/ })).not.toBeChecked();
expect(g.getByRole("checkbox", { name: /AWPCA_Sprint_3/ })).toBeDisabled();
await userEvent.click(g.getByRole("checkbox", { name: /AWPCA_Sprint_4/ }));
expect(g.getByRole("checkbox", { name: /AWPCA_Sprint_3/ })).toBeEnabled();
});
it("задача в обоих выбранных спринтах — одна карточка", async () => {
hub(agile([s102(), sprint({ id: 104, name: "AWPCA_Sprint_5", issues: [iss("AURORA-1", { story_points: 5 })], count: 1 })]));
open("/app/jira/sprint?project=AWPCA");
const p = await page();
await within(p).findByText("Тема AURORA-1");
expect(within(p).getAllByText("Тема AURORA-1")).toHaveLength(1);
});
it("?sprint=999 и ?sprint=abc — все активные", async () => {
hub(agile([s102(), s103()]));
open("/app/jira/sprint?project=AWPCA&sprint=999");
expect(await within(await page()).findByText("Тема AURORA-1")).toBeInTheDocument();
expect(screen.queryByText("Тема AURORA-88")).toBeNull();
});
it("мусорный ?sprint=abc — все активные", async () => {
hub(agile());
open("/app/jira/sprint?project=AWPCA&sprint=abc");
expect(await within(await page()).findByText("Тема AURORA-1")).toBeInTheDocument();
});
it("?sprint=103 (будущий) — только он", async () => {
hub(agile([s102(), s103()]));
open("/app/jira/sprint?project=AWPCA&sprint=103");
const p = await page();
expect(await within(p).findByText("Тема AURORA-88")).toBeInTheDocument();
expect(within(p).queryByText("Тема AURORA-1")).toBeNull();
});
it("устаревший ?sprint= (закрытый) — все активные", async () => {
hub(agile([s102(), sprint({ id: 101, name: "Закрытый", state: "closed", issues: [iss("AURORA-99")] })]));
open("/app/jira/sprint?project=AWPCA&sprint=101");
expect(await within(await page()).findByText("Тема AURORA-1")).toBeInTheDocument();
expect(screen.queryByText("Тема AURORA-99")).toBeNull();
});
it("мусорный ?sprint=abc — первый активный", async () => {
hub(agile());
open("/app/jira/sprint?project=AWPCA&sprint=abc");
expect(await within(await page()).findByText("Тема AURORA-1")).toBeInTheDocument();
});
it("нет активного — «Нет активного спринта» и ссылка «Бэклог»", async () => {
hub(agile([sprint({ id: 103, name: "F", state: "future" })]));
it("нет активного, есть будущий — «Нет активного спринта», «Бэклог», выбор доступен; выбор будущего показывает доску", async () => {
hub(agile([s103()]));
open("/app/jira/sprint?project=AWPCA");
const p = within(await page());
expect(await p.findByText("Нет активного спринта")).toBeInTheDocument();
expect(p.getByRole("link", { name: "Бэклог" })).toHaveAttribute("href", "/app/jira/backlog?project=AWPCA");
await userEvent.click(picker(await page()));
await userEvent.click(within(sprintGroup()).getByRole("checkbox", { name: /AWPCA_Sprint_4/ }));
expect(await p.findByText("Тема AURORA-88")).toBeInTheDocument();
expect(p.queryByText("Нет активного спринта")).toBeNull();
});
it("«Исполнитель на доске спринта»: только его карточки, числа колонок по видимым; на «Бэклоге» «все»", async () => {
const sp = s102({ issues: [iss("AURORA-1", { assignee: "ivanov" }), iss("AURORA-2", { assignee: "petrov" }), iss("AURORA-3", { assignee: "ivanov", status: "Готово", done: true })] });
hub(agile([sp]));
open("/app/jira/sprint?project=AWPCA");
const p = await page();
await within(p).findByText("Тема AURORA-2");
await userEvent.selectOptions(within(p).getByLabelText("Исполнитель"), "ivanov");
await waitFor(() => expect(within(p).queryByText("Тема AURORA-2")).toBeNull());
expect(within(p).getByText("Тема AURORA-1")).toBeInTheDocument();
const cols = Array.from(p.querySelectorAll(".agile-col"));
expect(cols.map((c) => c.querySelector(".agile-col__count")!.textContent)).toEqual(["1", "1"]);
cleanup();
hub(agile([sp]));
open("/app/jira/backlog?project=AWPCA");
const b = await screen.findByTestId("page-backlog");
expect(await within(b).findByLabelText("Исполнитель")).toHaveValue("");
});
it("«Скрыть выполненные» убирает выполненные карточки", async () => {
hub(agile());
open("/app/jira/sprint?project=AWPCA");
const p = await page();
await within(p).findByText("Тема AURORA-2");
await userEvent.click(within(p).getByRole("checkbox", { name: "Скрыть выполненные" }));
expect(within(p).queryByText("Тема AURORA-2")).toBeNull();
expect(within(p).getByText("Тема AURORA-1")).toBeInTheDocument();
const cols = Array.from(p.querySelectorAll(".agile-col"));
expect(cols.map((c) => c.querySelector(".agile-col__count")!.textContent)).toEqual(["1", "0", "1"]);
});
it("заголовки колонок — h3 (T10.3)", async () => {
hub(agile());
open("/app/jira/sprint?project=AWPCA");
const p = await page();
await within(p).findByText("Тема AURORA-1");
expect(within(p).getAllByRole("heading", { level: 3 }).map((h) => h.textContent)).toEqual(["К выполнению", "Готово", "Без колонки"]);
});
it("«Скрытая колонка»: снять «Готово» — колонки нет, на «Колонки» — 1; после повторного монтажа так же", async () => {
hub(agile());
open("/app/jira/sprint?project=AWPCA");
let p = await page();
await within(p).findByText("Тема AURORA-1");
await userEvent.click(within(p).getByRole("button", { name: /^Колонки/ }));
await userEvent.click(within(screen.getByRole("group", { name: "Колонки" })).getByRole("checkbox", { name: "Готово" }));
expect(colNames(p)).toEqual(["К выполнению", "Без колонки"]);
expect(within(p).getByRole("button", { name: /^Колонки/ })).toHaveTextContent("Колонки1");
cleanup();
open("/app/jira/sprint?project=AWPCA");
p = await page();
await within(p).findByText("Тема AURORA-1");
expect(colNames(p)).toEqual(["К выполнению", "Без колонки"]);
expect(within(p).getByRole("button", { name: /^Колонки/ })).toHaveTextContent("Колонки1");
});
it("последнюю видимую колонку снять нельзя", async () => {
hub(agile());
open("/app/jira/sprint?project=AWPCA");
const p = await page();
await within(p).findByText("Тема AURORA-1");
await userEvent.click(within(p).getByRole("button", { name: /^Колонки/ }));
const g = within(screen.getByRole("group", { name: "Колонки" }));
await userEvent.click(g.getByRole("checkbox", { name: "Готово" }));
await userEvent.click(g.getByRole("checkbox", { name: "Без колонки" }));
expect(colNames(p)).toEqual(["К выполнению"]);
expect(g.getByRole("checkbox", { name: "К выполнению" })).toBeDisabled();
});
it("скрытая колонка, которой больше нет, не ломает меню и не считается", async () => {
localStorage.setItem("sdlc-hub-agile", JSON.stringify({ AWPCA: { sprint: { hiddenColumns: ["Удалённая"] } } }));
hub(agile());
open("/app/jira/sprint?project=AWPCA");
const p = await page();
await within(p).findByText("Тема AURORA-1");
expect(colNames(p)).toEqual(["К выполнению", "Готово", "Без колонки"]);
const btn = within(p).getByRole("button", { name: /^Колонки/ });
expect(btn).toHaveTextContent(/^Колонки$/);
await userEvent.click(btn);
expect(within(screen.getByRole("group", { name: "Колонки" })).getAllByRole("checkbox")).toHaveLength(3);
});
it("эпик на доске спринта — карточка, учитывается в числе колонки", async () => {
hub(agile([s102({ issues: [iss("AURORA-1"), iss("AURORA-E", { issue_type: "Эпик" })] })]));
open("/app/jira/sprint?project=AWPCA");
const p = await page();
expect(await within(p).findByText("Тема AURORA-E")).toBeInTheDocument();
const cols = Array.from(p.querySelectorAll(".agile-col"));
expect(cols[0].querySelector(".agile-col__count")!.textContent).toBe("2");
});
it("нет активного — «Нет активного спринта» и ссылка «Бэклог»", async () => {
hub(agile([sprint({ id: 105, name: "C", state: "closed" })]));
open("/app/jira/sprint?project=AWPCA");
const p = within(await page());
expect(await p.findByText("Нет активного спринта")).toBeInTheDocument();
expect(p.getByRole("link", { name: "Бэклог" })).toHaveAttribute("href", "/app/jira/backlog?project=AWPCA");
expect(p.queryByRole("button", { name: /^Колонки/ })).toBeNull(); // M-7: доски нет — меню не нужно
});
it("T7.2: меню «Колонки» строится по задачам без фильтров — «Без колонки» не пропадает из меню вместе с поиском", async () => {
localStorage.setItem("sdlc-hub-agile", JSON.stringify({ AWPCA: { sprint: { hiddenColumns: ["Без колонки"] } } }));
hub(agile());
open("/app/jira/sprint?project=AWPCA");
const p = within(await page());
await p.findByText("Тема AURORA-1");
await userEvent.type(p.getByRole("searchbox", { name: "Поиск по ключу или теме" }), "AURORA-1");
expect(colNames(await page())).toEqual(["К выполнению", "Готово"]);
const btn = p.getByRole("button", { name: "Колонки, скрыто 1" });
await userEvent.click(btn);
const g = within(screen.getByRole("group", { name: "Колонки" }));
expect(g.getByRole("checkbox", { name: "Без колонки" })).not.toBeChecked();
await userEvent.click(g.getByRole("checkbox", { name: "Готово" }));
expect(JSON.parse(localStorage.getItem("sdlc-hub-agile")!).AWPCA.sprint.hiddenColumns).toEqual(["Готово", "Без колонки"]);
});
it("агент без досок — «Нужен агент v15 и обновление из Jira» и «Обновить из Jira»", async () => {

View File

@ -1,16 +1,21 @@
import { Link, useSearchParams } from "react-router";
import type { Project } from "../../../api/types.ts";
import { PageHeader, Panel, Segmented } from "../../../ui/index.ts";
import { PageHeader, Panel } from "../../../ui/index.ts";
import type { AgileSprint } from "../api/types.ts";
import { RefreshButton } from "../RefreshButton.tsx";
import { AgileGate } from "./AgileGate.tsx";
import { BoardColumns } from "./BoardColumns.tsx";
import { AgileToolbar } from "./AgileToolbar.tsx";
import { ColumnsMenu } from "./ColumnsMenu.tsx";
import { columnsOf } from "./columns.ts";
import { assigneeOptions, matches, parseSprintIds, selectableSprints, sprintIssues } from "./filters.ts";
import { SprintPicker } from "./SprintPicker.tsx";
import { useAgileView } from "./useAgileView.ts";
import { daysLeft, fmtRange, fmtSp, tasksWord } from "./model.ts";
import "./agile.css";
function SprintHead({ s }: { s: AgileSprint }) {
const left = daysLeft(s.end_date, new Date());
const left = s.state === "active" ? daysLeft(s.end_date, new Date()) : null;
return (
<header className="agile-section__head">
<h2 className="agile-section__title">{s.name}</h2>
@ -23,35 +28,41 @@ function SprintHead({ s }: { s: AgileSprint }) {
);
}
// «Спринт» (spec web-app): колонки доски активного спринта; только просмотр
// «Спринт» (spec web-app): колонки доски выбранных спринтов (по умолчанию все активные); только просмотр
export function SprintPage({ project }: { project: Project }) {
const [params] = useSearchParams();
const wanted = params.get("sprint");
const [params, setParams] = useSearchParams();
const { view, set, setQ, filter, reset } = useAgileView(project.label, "sprint");
return (
<div data-testid="page-sprint" className="agile agile--wide">
<PageHeader title="Спринт" actions={<RefreshButton />} />
<AgileGate project={project} kind="scrum">
{(d) => {
const active = d.sprints.filter((s) => s.state === "active");
// устаревший или мусорный ?sprint= — первый активный (Review Focus 2)
const sprint = active.find((s) => String(s.id) === wanted) ?? active[0];
if (!sprint) {
return (
<Panel className="agile-section">
<p className="agile__empty">Нет активного спринта</p>
<Link className="agile__link" to={`/jira/backlog?project=${encodeURIComponent(project.label)}`}>Бэклог</Link>
</Panel>
);
}
const proj = encodeURIComponent(project.label);
const selectable = selectableSprints(d.sprints);
const ids = parseSprintIds(params.get("sprint"), d.sprints);
const chosen = selectable.filter((s) => ids.includes(s.id));
const issues = sprintIssues(chosen);
const shown = issues.filter((i) => matches(i, filter));
const cols = columnsOf(d.board?.columns ?? null, shown);
const allNames = columnsOf(d.board?.columns ?? null, issues).map((c) => c.name); // меню — по задачам без фильтров (T7.2)
// выбор спринтов — в адресе, без новой записи истории
const pick = (next: number[]) => setParams((p) => { const n = new URLSearchParams(p); n.set("sprint", next.join(",")); return n; }, { replace: true });
return (
<>
{active.length > 1 && (
<Segmented label="Спринты" value={String(sprint.id)}
options={active.map((s) => ({ value: String(s.id), label: s.name, href: `/jira/sprint?project=${proj}&sprint=${s.id}` }))} />
<AgileToolbar filter={filter} setQ={setQ} set={set} onReset={reset} assignees={assigneeOptions(issues, view.assignee)} showHideDone>
{selectable.length > 0 && <SprintPicker sprints={selectable} selected={ids} onChange={pick} />}
{chosen.length > 0 && <ColumnsMenu names={allNames} hidden={view.hiddenColumns} onChange={(hiddenColumns) => set({ hiddenColumns })} />}
</AgileToolbar>
{chosen.length === 0 ? (
<Panel className="agile-section">
<p className="agile__empty">Нет активного спринта</p>
<Link className="agile__link" to={`/jira/backlog?project=${encodeURIComponent(project.label)}`}>Бэклог</Link>
</Panel>
) : (
<>
{chosen.map((s) => <SprintHead key={s.id} s={s} />)}
<BoardColumns columns={cols} hidden={view.hiddenColumns} />
</>
)}
<SprintHead s={sprint} />
<BoardColumns columns={columnsOf(d.board?.columns ?? null, sprint.issues)} />
</>
);
}}

View File

@ -0,0 +1,31 @@
// выбор спринтов доски: активные и будущие; последнюю отметку снять нельзя
import { Badge, Checkbox, Dropdown, IconChevronDown } from "../../../ui/index.ts";
import type { AgileSprint } from "../api/types.ts";
export function SprintPicker({ sprints, selected, onChange }: {
sprints: AgileSprint[]; selected: number[]; onChange(ids: number[]): void;
}) {
const chosen = sprints.filter((s) => selected.includes(s.id));
const label = chosen.length === 1 ? chosen[0].name : chosen.length === 0 ? "Спринты" : `Спринты: ${chosen.length}`;
const flip = (id: number, on: boolean) => onChange(sprints.map((s) => s.id).filter((x) => (x === id ? on : selected.includes(x))));
return (
<Dropdown className="agile-sprint-picker" button={({ open, toggle }) => (
<button type="button" className="ui-input multi-btn" aria-label={chosen.length === 1 ? `Спринты: ${label}` : label} aria-haspopup="true" aria-expanded={open} onClick={toggle}>
<span className="lbl">{label}</span><IconChevronDown size={16} />
</button>
)}>
{() => (
<div className="dd-list" role="group" aria-label="Спринты">
{sprints.map((s) => {
const on = selected.includes(s.id);
return (
<Checkbox key={s.id} className="dd-item" checked={on} disabled={on && selected.length === 1}
onChange={(e) => flip(s.id, e.target.checked)}
label={<>{s.name} <Badge>{s.state === "active" ? "активный" : "будущий"}</Badge></>} />
);
})}
</div>
)}
</Dropdown>
);
}

View File

@ -0,0 +1,35 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { TypeIcon } from "./TypeIcon.tsx";
describe("TypeIcon — значок типа по образцу Jira", () => {
it.each([
["Эпик", "epic"],
["Story", "story"],
["Задача", "task"],
["Ошибка", "bug"],
["Подзадача", "subtask"],
["Риск", "other"],
])("«%s» → вид %s: role=img, aria-label — исходное имя, класс agile-type--<вид>", (type, kind) => {
const { container } = render(<TypeIcon type={type} />);
const el = screen.getByRole("img", { name: type });
expect(el).toHaveAttribute("aria-label", type);
expect(el).toHaveClass("agile-type", `agile-type--${kind}`);
expect(container.querySelector("svg")).not.toBeNull();
});
it("пустая строка и пробелы — «Тип не указан», у значка есть доступное имя", () => {
render(<TypeIcon type=" " />);
const el = screen.getByRole("img", { name: "Тип не указан" });
expect(el).toHaveClass("agile-type--other");
render(<TypeIcon type="" />);
expect(screen.getAllByRole("img", { name: "Тип не указан" })).toHaveLength(2);
});
it("null — «Тип не указан», не «Задача»", () => {
render(<TypeIcon type={null} />);
const el = screen.getByRole("img", { name: "Тип не указан" });
expect(el).toHaveAttribute("aria-label", "Тип не указан");
expect(el).toHaveClass("agile-type--other");
});
});

View File

@ -1,9 +1,19 @@
import { IconBoard, IconCritical, IconCheck } from "../../../ui/index.ts";
import { IconBookmark, IconCheck, IconCircle, IconCritical, IconDot, IconSquares } from "../../../ui/index.ts";
import { typeKind, type TypeKind } from "./model.ts";
// значок типа задачи: эпик, ошибка, прочее — задача; тип — в aria-label и title
// значок типа задачи по образцу Jira: эпик — молния, история — закладка, задача — галочка, ошибка — круг, подзадача — два квадрата
const ICONS: Record<TypeKind, typeof IconCritical> = {
epic: IconCritical, story: IconBookmark, task: IconCheck, bug: IconCircle, subtask: IconSquares, other: IconDot,
};
// тип — в aria-label и title; без типа — «Тип не указан»
export function TypeIcon({ type }: { type: string | null }) {
const label = type ?? "Задача";
const kind = /^(Эпик|Epic|Фича)$/i.test(label) ? "epic" : /^(Ошибка|Bug)$/i.test(label) ? "bug" : "task";
const Icon = kind === "epic" ? IconBoard : kind === "bug" ? IconCritical : IconCheck;
return <span className={`agile-type agile-type--${kind}`} role="img" aria-label={label} title={label}><Icon size={16} /></span>;
const kind = typeKind(type);
const label = type?.trim() || "Тип не указан";
const Icon = ICONS[kind];
return (
<span className={`agile-type agile-type--${kind}`} role="img" aria-label={label} title={label}>
<Icon size={12} />
</span>
);
}

View File

@ -23,10 +23,13 @@
.agile-row__assignee{flex:0 1 160px;min-width:0;color:var(--color-text-secondary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.agile-row__sp{flex:0 0 32px;text-align:right;color:var(--color-text-secondary)}
.agile-row__mark{flex:0 0 auto;font-size:var(--fs-caption);color:var(--color-status-warning)}
.agile-type{display:inline-flex;flex:0 0 auto;color:var(--color-text-secondary)}
.agile-type--epic{color:var(--k-ui)}
.agile-type--bug{color:var(--color-status-error)}
.agile-type--task{color:var(--color-status-success)}
.agile-type{display:inline-flex;align-items:center;justify-content:center;flex:0 0 auto;width:16px;height:16px;border-radius:var(--radius-xs);
color:var(--color-text-on-primary);background:var(--color-text-secondary)}
.agile-type--epic{background:var(--k-ui)}
.agile-type--story{background:var(--color-status-success)}
.agile-type--task{background:var(--k-dev)}
.agile-type--subtask{background:var(--k-analytics)}
.agile-type--bug{background:var(--color-status-error)}
.agile-past{display:flex;flex-direction:column;gap:var(--space-2)}
.agile-past>summary{cursor:pointer;font-weight:600;padding:var(--space-2) 0}
.agile-past__sprint{background:var(--color-bg-surface);border:1px solid var(--color-border-default);border-radius:var(--radius-lg);padding:var(--space-2) var(--space-4)}
@ -35,17 +38,22 @@
.agile-row{flex-wrap:wrap}
.agile-row__assignee{flex-basis:100%}
}
/* «Спринт»: страница во всю ширину, колонки прокручиваются внутри контейнера */
.agile--wide{min-width:0}
.agile-columns{display:flex;align-items:flex-start;gap:var(--space-3);overflow-x:auto;padding-bottom:var(--space-2);min-width:0}
.agile-col{flex:0 0 280px;min-width:0;display:flex;flex-direction:column;gap:var(--space-2);
/* «Спринт» и «Kanban»: доска занимает высоту окна под шапкой и панелью фильтров (ограничения max-width у .page--board нет);
колонки одной высоты, список колонки прокручивается внутри, горизонтальная прокрутка — у .agile-columns и в окне (T10.3) */
.agile--wide{box-sizing:border-box;min-width:0;height:calc(100dvh - 32px)} /* 32px — нижний отступ .page */
.agile--wide>*{flex:0 0 auto}
.agile--wide>.agile-columns{flex:1 1 0}
.agile--wide>.agile-past[open]{max-height:40dvh;overflow:auto}
.agile-columns{display:flex;align-items:stretch;gap:var(--space-3);overflow-x:auto;padding-bottom:var(--space-2);min-width:0;min-height:360px}
.agile-col{flex:0 0 280px;min-width:0;min-height:0;display:flex;flex-direction:column;gap:var(--space-2);
background:var(--color-bg-subtle);border:1px solid var(--color-border-default);border-radius:var(--radius-xl);padding:var(--space-3)}
@media (max-width:639px){.agile--wide{height:calc(100dvh - 56px - 32px)}.agile--wide>.agile-columns{min-height:240px}} /* 56px — закреплённая строка «Меню» */
.agile-col.is-extra{border-style:dashed}
.agile-col__head{display:flex;align-items:center;justify-content:space-between;gap:var(--space-2)}
.agile-col__name{margin:0;font-size:var(--fs-body);line-height:var(--lh-body);font-weight:700;overflow-wrap:anywhere}
.agile-col__count{font-size:var(--fs-caption);color:var(--color-text-secondary)}
.agile-col__caption{margin:0;font-size:var(--fs-caption);color:var(--color-text-secondary)}
.agile-col__list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:var(--space-2)}
.agile-col__list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:var(--space-2);flex:1 1 auto;min-height:0;overflow-y:auto}
.agile-card{display:flex;flex-direction:column;gap:var(--space-2);min-width:0;padding:var(--space-3);cursor:pointer;
background:var(--color-bg-surface);border:1px solid var(--color-border-default);border-radius:var(--radius-lg);
font-size:var(--fs-body);line-height:var(--lh-body)}
@ -55,3 +63,32 @@
.agile-card__summary{overflow-wrap:anywhere}
.agile-card__foot{display:flex;align-items:center;gap:var(--space-2);min-width:0}
.agile-card__assignee{flex:1 1 auto;min-width:0;color:var(--color-text-secondary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
/* панель фильтров */
.agile-toolbar{display:flex;flex-wrap:wrap;align-items:center;gap:var(--space-3)}
.agile-toolbar__search{flex:1 1 220px;min-width:0}
.agile-toolbar__field{display:inline-flex;align-items:center;gap:var(--space-2);margin:0;font-size:var(--fs-body);color:var(--color-text-secondary)}
.agile-toolbar .ui-select{width:auto;flex:0 1 220px;min-width:160px}
.agile-section__toggle{display:inline-flex;align-items:center;gap:var(--space-2);margin:0;padding:0;border:0;background:none;color:inherit;font:inherit;cursor:pointer}
.agile-section__toggle:focus-visible{outline:2px solid var(--color-focus-ring);outline-offset:2px}
/* «Бэклог»: панель «Эпики» слева; на узком окне — выбор «Эпик» в панели фильтров */
.agile-backlog{display:grid;grid-template-columns:minmax(0,1fr);gap:var(--space-4);align-items:start}
.agile-backlog.has-epics{grid-template-columns:240px minmax(0,1fr)}
.agile-backlog__main{display:flex;flex-direction:column;gap:var(--space-4);min-width:0}
.agile-epics{min-width:0;background:var(--color-bg-surface);border:1px solid var(--color-border-default);border-radius:var(--radius-lg);padding:var(--space-2)}
.agile-epics__list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:var(--space-1)}
.agile-epics__item{display:flex;align-items:baseline;flex-wrap:wrap;gap:0 var(--space-2);width:100%;margin:0;padding:var(--space-2);text-align:left;
border:0;border-radius:var(--radius-md);background:none;color:inherit;font:inherit;font-size:var(--fs-body);cursor:pointer}
.agile-epics__item:hover{background:var(--color-bg-hover)}
.agile-epics__item[aria-pressed="true"]{background:var(--color-bg-subtle);font-weight:600}
.agile-epics__item:focus-visible{outline:2px solid var(--color-focus-ring);outline-offset:2px}
.agile-epics__title{flex:1 1 100%;min-width:0;overflow-wrap:anywhere}
.agile-epics__key{font-family:var(--font-mono);font-size:var(--fs-caption);color:var(--color-text-secondary)}
.agile-epics__count{margin-left:auto;font-size:var(--fs-caption);color:var(--color-text-secondary)}
.agile-epic-select{display:none}
.agile-epic-select.is-forced{display:inline-flex}
@container page (max-width: 899px){
.agile-backlog.has-epics{grid-template-columns:minmax(0,1fr)}
.agile-epics{display:none}
.agile-epic-select{display:inline-flex}
.agile-epics-toggle{display:none}
}

View File

@ -0,0 +1,85 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_VIEW, type AgileView } from "./filters.ts";
import { AGILE_KEY, readView, saveView } from "./agilePref.ts";
const view = (over: Partial<AgileView> = {}): AgileView => ({ ...DEFAULT_VIEW, ...over });
const stored = () => JSON.parse(localStorage.getItem(AGILE_KEY) ?? "null");
beforeEach(() => localStorage.clear());
afterEach(() => vi.restoreAllMocks());
describe("agilePref", () => {
it("ключ хранилища — sdlc-hub-agile", () => {
expect(AGILE_KEY).toBe("sdlc-hub-agile");
});
it("нет ключа → DEFAULT_VIEW", () => {
expect(readView("AWPCA", "backlog")).toEqual(DEFAULT_VIEW);
});
it("запись AWPCA/backlog не меняет AWPCA/sprint и STOCKS/backlog", () => {
saveView("STOCKS", "backlog", view({ assignee: "petrov" }));
saveView("AWPCA", "sprint", view({ hideDone: true }));
saveView("AWPCA", "backlog", view({ epic: "AURORA-10", collapsed: ["AURORA-10"] }));
expect(readView("AWPCA", "backlog")).toEqual(view({ epic: "AURORA-10", collapsed: ["AURORA-10"] }));
expect(readView("AWPCA", "sprint")).toEqual(view({ hideDone: true }));
expect(readView("STOCKS", "backlog")).toEqual(view({ assignee: "petrov" }));
expect(readView("AWPCA", "kanban")).toEqual(DEFAULT_VIEW);
});
it("запись не хранит поиск q", () => {
saveView("AWPCA", "backlog", { ...view(), q: "секрет" } as AgileView);
expect(stored().AWPCA.backlog).not.toHaveProperty("q");
});
it("мусор: «oops» → DEFAULT_VIEW", () => {
localStorage.setItem(AGILE_KEY, "oops");
expect(readView("AWPCA", "backlog")).toEqual(DEFAULT_VIEW);
});
it("испорченный JSON: saveView перезаписывает мусор, readView возвращает сохранённое", () => {
localStorage.setItem(AGILE_KEY, "oops");
saveView("AWPCA", "backlog", view({ hideDone: true, epic: "AURORA-10" }));
expect(readView("AWPCA", "backlog")).toEqual(view({ hideDone: true, epic: "AURORA-10" }));
});
it("мусор: «[1]» → DEFAULT_VIEW", () => {
localStorage.setItem(AGILE_KEY, "[1]");
expect(readView("AWPCA", "backlog")).toEqual(DEFAULT_VIEW);
});
it("мусор: {\"AWPCA\": 5} → DEFAULT_VIEW", () => {
localStorage.setItem(AGILE_KEY, JSON.stringify({ AWPCA: 5 }));
expect(readView("AWPCA", "backlog")).toEqual(DEFAULT_VIEW);
});
it("поле неверного типа — дефолт поля, годные поля сохраняются", () => {
localStorage.setItem(AGILE_KEY, JSON.stringify({ AWPCA: { backlog: { collapsed: "x", hideDone: "yes", epic: "AURORA-10" } } }));
const v = readView("AWPCA", "backlog");
expect(v.collapsed).toEqual([]);
expect(v.hideDone).toBe(false);
expect(v.epic).toBe("AURORA-10");
});
it("массив с не-строками отбрасывается по элементам", () => {
localStorage.setItem(AGILE_KEY, JSON.stringify({ AWPCA: { kanban: { hiddenColumns: ["Готово", 3, null] } } }));
expect(readView("AWPCA", "kanban").hiddenColumns).toEqual(["Готово"]);
});
it("epicsPanel: булево сохраняется; не-булево — дефолт true", () => {
localStorage.setItem(AGILE_KEY, JSON.stringify({ AWPCA: { backlog: { epicsPanel: false } } }));
expect(readView("AWPCA", "backlog").epicsPanel).toBe(false);
localStorage.setItem(AGILE_KEY, JSON.stringify({ AWPCA: { backlog: { epicsPanel: 0 } } }));
expect(readView("AWPCA", "backlog").epicsPanel).toBe(true);
});
it("saveView не бросает, если setItem бросает", () => {
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { throw new Error("quota"); });
expect(() => saveView("AWPCA", "backlog", view({ hideDone: true }))).not.toThrow();
});
it("readView → DEFAULT_VIEW, если getItem бросает", () => {
vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => { throw new Error("blocked"); });
expect(readView("AWPCA", "backlog")).toEqual(DEFAULT_VIEW);
});
});

View File

@ -0,0 +1,60 @@
// запоминание вида страниц доски в localStorage: { "<метка>": { "backlog"|"sprint"|"kanban": AgileView } }
// любая ошибка хранилища — значения по умолчанию, наружу не выходит
import { DEFAULT_VIEW, type AgilePage, type AgileView } from "./filters.ts";
export const AGILE_KEY = "sdlc-hub-agile";
type Obj = Record<string, unknown>;
const isObj = (v: unknown): v is Obj => typeof v === "object" && v !== null && !Array.isArray(v);
const str = (v: unknown, d: string): string => (typeof v === "string" ? v : d);
const bool = (v: unknown, d: boolean): boolean => (typeof v === "boolean" ? v : d);
const strArr = (v: unknown): string[] => (Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : []);
// поле за полем: неверный тип — значение по умолчанию, годные поля сохраняются
function parseView(raw: unknown): AgileView {
if (!isObj(raw)) return { ...DEFAULT_VIEW, collapsed: [], hiddenColumns: [] };
return {
assignee: str(raw.assignee, DEFAULT_VIEW.assignee),
epic: str(raw.epic, DEFAULT_VIEW.epic),
hideDone: bool(raw.hideDone, DEFAULT_VIEW.hideDone),
collapsed: strArr(raw.collapsed),
hiddenColumns: strArr(raw.hiddenColumns),
epicsPanel: bool(raw.epicsPanel, DEFAULT_VIEW.epicsPanel),
};
}
function readRoot(): Obj {
const raw = localStorage.getItem(AGILE_KEY);
if (raw === null) return {};
const parsed: unknown = JSON.parse(raw);
return isObj(parsed) ? parsed : {};
}
export function readView(label: string, page: AgilePage): AgileView {
try {
const project = readRoot()[label];
return parseView(isObj(project) ? project[page] : undefined);
} catch {
return parseView(undefined);
}
}
export function saveView(label: string, page: AgilePage, v: AgileView): void {
try {
let root: Obj;
try {
root = readRoot();
} catch {
root = {}; // испорченный JSON перезаписывается, иначе вид не сохранится никогда
}
const project = isObj(root[label]) ? root[label] : {};
const view: AgileView = {
assignee: v.assignee, epic: v.epic, hideDone: v.hideDone,
collapsed: v.collapsed, hiddenColumns: v.hiddenColumns, epicsPanel: v.epicsPanel,
};
root[label] = { ...project, [page]: view };
localStorage.setItem(AGILE_KEY, JSON.stringify(root));
} catch {
// хранилище недоступно или переполнено — вид просто не запоминается
}
}

View File

@ -19,3 +19,9 @@ export function columnsOf(columns: BoardColumn[] | null, issues: AgileIssue[]):
if (rest.length) out.push({ name: NO_COLUMN, issues: rest, extra: true });
return out;
}
// скрытые колонки, которые есть на доске; если скрыты все (колонки переименовали) — не скрыто ничего, доска не пустеет молча
export function effectiveHidden(names: string[], hidden: string[]): string[] {
const gone = names.filter((n) => hidden.includes(n));
return gone.length >= names.length ? [] : gone;
}

View File

@ -0,0 +1,226 @@
import { describe, expect, it } from "vitest";
import type { AgileIssue, AgileResponse, AgileSprint } from "../api/types.ts";
import {
DEFAULT_VIEW, NONE, assigneeOptions, byRank, epicEntries, hasFilters, matches, parseSprintIds, selectableSprints, sprintIssues,
} from "./filters.ts";
const iss = (key: string, over: Partial<AgileIssue> = {}): AgileIssue => ({
key, summary: `Тема ${key}`, issue_type: "Задача", status: "В работе", priority: null, assignee: null,
epic_key: null, story_points: null, rank: "0|a", done: false, ...over,
});
const sprint = (over: Partial<AgileSprint>): AgileSprint => ({
id: 1, name: "S", state: "active", start_date: "2026-10-12T09:00:00.000+0300", end_date: "2026-10-26T09:00:00.000+0300",
complete_date: null, goal: null, count: 0, count_done: 0, unestimated: 0, points: 0, points_done: 0, issues: [], ...over,
});
const F = { q: "", assignee: "", epic: "", hideDone: false };
describe("matches", () => {
const a = iss("AURORA-1", { summary: "Тема про отчёт", assignee: "ivanov", epic_key: "AURORA-10" });
it("пустой фильтр пропускает всё", () => {
expect(matches(iss("X-1", { done: true }), F)).toBe(true);
});
it("q — подстрока без учёта регистра по key и summary, пробелы по краям отбрасываются", () => {
expect(matches(iss("AURORA-1"), { ...F, q: " aurora-1 " })).toBe(true);
expect(matches(iss("AURORA-10"), { ...F, q: " aurora-1 " })).toBe(true);
expect(matches(iss("AURORA-2"), { ...F, q: " aurora-1 " })).toBe(false);
expect(matches(a, { ...F, q: "тема" })).toBe(true);
expect(matches(a, { ...F, q: "ОТЧЁТ" })).toBe(true);
expect(matches(a, { ...F, q: "нет такого" })).toBe(false);
expect(matches(iss("X-1", { summary: null }), { ...F, q: "x-1" })).toBe(true);
});
it("assignee: NONE — только null; точное совпадение иначе", () => {
expect(matches(iss("A-1", { assignee: null }), { ...F, assignee: NONE })).toBe(true);
expect(matches(iss("A-1", { assignee: "" }), { ...F, assignee: NONE })).toBe(false);
expect(matches(iss("A-1", { assignee: "ivanov" }), { ...F, assignee: NONE })).toBe(false);
expect(matches(a, { ...F, assignee: "ivanov" })).toBe(true);
expect(matches(a, { ...F, assignee: "petrov" })).toBe(false);
});
it("epic: NONE — только epic_key === null; точное совпадение иначе", () => {
expect(matches(iss("A-1", { epic_key: null }), { ...F, epic: NONE })).toBe(true);
expect(matches(a, { ...F, epic: NONE })).toBe(false);
expect(matches(a, { ...F, epic: "AURORA-10" })).toBe(true);
expect(matches(a, { ...F, epic: "AURORA-11" })).toBe(false);
});
it("hideDone скрывает выполненные", () => {
expect(matches(iss("A-1", { done: true }), { ...F, hideDone: true })).toBe(false);
expect(matches(iss("A-1"), { ...F, hideDone: true })).toBe(true);
});
it("условия складываются по И", () => {
expect(matches(a, { q: "aurora", assignee: "ivanov", epic: "AURORA-10", hideDone: true })).toBe(true);
expect(matches(a, { q: "aurora", assignee: "ivanov", epic: "AURORA-11", hideDone: true })).toBe(false);
expect(matches(iss("A-1", { done: true, assignee: "ivanov" }), { ...F, assignee: "ivanov", hideDone: true })).toBe(false);
});
});
describe("hasFilters", () => {
it("пустой фильтр — false, любое поле — true", () => {
expect(hasFilters(F)).toBe(false);
expect(hasFilters({ ...F, q: "x" })).toBe(true);
expect(hasFilters({ ...F, q: " " })).toBe(false);
expect(hasFilters({ ...F, assignee: "ivanov" })).toBe(true);
expect(hasFilters({ ...F, epic: NONE })).toBe(true);
expect(hasFilters({ ...F, hideDone: true })).toBe(true);
});
});
describe("DEFAULT_VIEW и NONE", () => {
it("значения по ТЗ", () => {
expect(DEFAULT_VIEW).toEqual({ assignee: "", epic: "", hideDone: false, collapsed: [], hiddenColumns: [], epicsPanel: true });
expect(NONE).toBe("-");
});
});
describe("assigneeOptions", () => {
it("«все», «Без исполнителя», затем исполнители по алфавиту ru", () => {
const issues = [iss("A-1", { assignee: "Яковлев" }), iss("A-2", { assignee: null }), iss("A-3", { assignee: "Афанасьев" }), iss("A-4", { assignee: "Афанасьев" })];
expect(assigneeOptions(issues, "")).toEqual([
{ value: "", label: "все" },
{ value: NONE, label: "Без исполнителя" },
{ value: "Афанасьев", label: "Афанасьев" },
{ value: "Яковлев", label: "Яковлев" },
]);
});
it("current, которого нет среди исполнителей, — в конце с подписью = значению", () => {
const opts = assigneeOptions([iss("A-1", { assignee: "ivanov" })], "gone");
expect(opts.at(-1)).toEqual({ value: "gone", label: "gone" });
expect(opts.map((o) => o.value)).toEqual(["", NONE, "ivanov", "gone"]);
});
it("current, который есть, не дублируется", () => {
const opts = assigneeOptions([iss("A-1", { assignee: "ivanov" })], "ivanov");
expect(opts.map((o) => o.value)).toEqual(["", NONE, "ivanov"]);
});
});
describe("epicEntries", () => {
// сценарий «Фильтр по эпику»: AURORA-10 — 3 в бэклоге и 2 в активном спринте → count 5
const epic = iss("AURORA-10", { issue_type: "Эпик", summary: "Большой эпик", done: false, rank: "0|b" });
const data = (over: Partial<AgileResponse> = {}): AgileResponse => ({
board: null,
sprints: [
sprint({ id: 1, state: "active", issues: [
iss("AURORA-1", { epic_key: "AURORA-10" }), iss("AURORA-2", { epic_key: "AURORA-10" }),
] }),
sprint({ id: 2, state: "future", issues: [] }),
],
backlog: [
iss("AURORA-20", { epic_key: "AURORA-10" }), iss("AURORA-21", { epic_key: "AURORA-10" }), iss("AURORA-22", { epic_key: "AURORA-10" }),
iss("AURORA-23"), iss("AURORA-24"), iss("AURORA-25"), iss("AURORA-26"), iss("AURORA-27"),
],
board_issues: null, ...over,
});
it("сценарий: строка эпика из бэклога, count 5 (3 бэклог + 2 активный спринт)", () => {
const d = data({ backlog: [epic, ...data().backlog!] });
const e = epicEntries(d);
expect(e).toEqual([{ key: "AURORA-10", title: "Большой эпик", count: 5, done: false }]);
});
it("эпик выполнен — done true", () => {
const d = data({ backlog: [{ ...epic, done: true }] });
expect(epicEntries(d)[0]).toMatchObject({ key: "AURORA-10", done: true });
});
it("строки эпиков из спринтов и бэклога — без повторов по key", () => {
const d = data({
sprints: [sprint({ id: 1, state: "active", issues: [epic] })],
backlog: [epic],
});
expect(epicEntries(d).map((e) => e.key)).toEqual(["AURORA-10"]);
});
it("эпик, на который ссылаются, но строки нет — в конце, title = ключ, done = false", () => {
const d = data({ backlog: [epic, iss("AURORA-40", { epic_key: "AURORA-99" })] });
const e = epicEntries(d);
expect(e.map((x) => x.key)).toEqual(["AURORA-10", "AURORA-99"]);
expect(e[1]).toEqual({ key: "AURORA-99", title: "AURORA-99", count: 1, done: false });
});
it("задачи прошлых спринтов не считаются в count; эпики в count не входят", () => {
const d = data({
sprints: [
sprint({ id: 1, state: "active", issues: [iss("AURORA-1", { epic_key: "AURORA-10" })] }),
sprint({ id: 9, state: "closed", issues: [iss("AURORA-90", { epic_key: "AURORA-10" }), iss("AURORA-91", { epic_key: "AURORA-10" })] }),
],
backlog: [epic],
});
expect(epicEntries(d)).toEqual([{ key: "AURORA-10", title: "Большой эпик", count: 1, done: false }]);
});
it("задача в двух активных/будущих местах считается один раз", () => {
const shared = iss("AURORA-1", { epic_key: "AURORA-10" });
const d = data({
sprints: [sprint({ id: 1, state: "active", issues: [shared] }), sprint({ id: 2, state: "future", issues: [shared] })],
backlog: [epic, shared],
});
expect(epicEntries(d)[0].count).toBe(1);
});
it("пустые данные — пустой список", () => {
expect(epicEntries({ board: null, sprints: [], backlog: null, board_issues: null })).toEqual([]);
});
});
describe("byRank", () => {
it("по rank строкой, null в конце, затем по key", () => {
const list = [iss("B-1", { rank: null }), iss("B-3", { rank: "0|b" }), iss("B-2", { rank: "0|a" }), iss("B-0", { rank: null })];
expect(list.sort(byRank).map((i) => i.key)).toEqual(["B-2", "B-3", "B-0", "B-1"]);
});
});
describe("sprintIssues", () => {
it("задача в двух спринтах — одна запись; rank null в конце", () => {
const moved = iss("AURORA-1", { rank: "0|a" });
const res = sprintIssues([
sprint({ id: 1, state: "active", issues: [moved, iss("AURORA-2", { rank: null })] }),
sprint({ id: 2, state: "future", issues: [moved, iss("AURORA-3", { rank: "0|b" })] }),
]);
expect(res.map((i) => i.key)).toEqual(["AURORA-1", "AURORA-3", "AURORA-2"]);
});
it("пустой список спринтов — пусто", () => {
expect(sprintIssues([])).toEqual([]);
});
});
describe("selectableSprints", () => {
it("закрытые отброшены; активные в порядке входа, затем будущие по start_date; без даты — последним", () => {
const res = selectableSprints([
sprint({ id: 1, state: "closed" }),
sprint({ id: 2, state: "future", start_date: null }),
sprint({ id: 3, state: "future", start_date: "2026-11-05T09:00:00.000+0300" }),
sprint({ id: 4, state: "active" }),
sprint({ id: 5, state: "future", start_date: "2026-11-01T09:00:00.000+0300" }),
]);
expect(res.map((s) => s.id)).toEqual([4, 5, 3, 2]);
});
});
describe("parseSprintIds", () => {
const sprints = [
sprint({ id: 101, state: "closed" }),
sprint({ id: 102, state: "active" }),
sprint({ id: 103, state: "future" }),
];
it("id из строки, только среди selectableSprints", () => {
expect(parseSprintIds("102,999", sprints)).toEqual([102]);
expect(parseSprintIds("999,103", sprints)).toEqual([103]);
});
it("если не осталось ни одного допустимого id — все активные", () => {
expect(parseSprintIds("999", sprints)).toEqual([102]);
expect(parseSprintIds("101", sprints)).toEqual([102]);
});
it("токены — только десятичные цифры (1e2, 0x10, 1.0 отбрасываются)", () => {
expect(parseSprintIds("1e2,0x66,102.0", sprints)).toEqual([102]);
expect(parseSprintIds("1e2,103", sprints)).toEqual([103]);
});
it("порядок — как в selectableSprints, не как в строке", () => {
expect(parseSprintIds("103,102", sprints)).toEqual([102, 103]);
});
it("«abc», null и пустая строка — все активные", () => {
expect(parseSprintIds("abc", sprints)).toEqual([102]);
expect(parseSprintIds(null, sprints)).toEqual([102]);
expect(parseSprintIds("", sprints)).toEqual([102]);
});
it("будущий спринт по явному id", () => {
expect(parseSprintIds("103", sprints)).toEqual([103]);
});
});

View File

@ -0,0 +1,104 @@
// модель фильтров экранов доски (Спринт, Бэклог, Kanban): чистые функции без React и хранилища
import type { AgileIssue, AgileResponse, AgileSprint } from "../api/types.ts";
import { isEpic, jiraDate } from "./model.ts";
export type AgilePage = "backlog" | "sprint" | "kanban";
// сохраняемый вид страницы (хранилище — agilePref.ts); поиск q и выбор спринтов сюда не входят
export interface AgileView {
assignee: string; epic: string; hideDone: boolean; collapsed: string[]; hiddenColumns: string[]; epicsPanel: boolean;
}
export const DEFAULT_VIEW: AgileView = { assignee: "", epic: "", hideDone: false, collapsed: [], hiddenColumns: [], epicsPanel: true };
// значение «Без исполнителя» / «Без эпика»
export const NONE = "-";
export interface IssueFilter { q: string; assignee: string; epic: string; hideDone: boolean }
// И всех условий; q — подстрока без учёта регистра в key или summary
export function matches(i: AgileIssue, f: IssueFilter): boolean {
const q = f.q.trim().toLowerCase();
if (q && !i.key.toLowerCase().includes(q) && !(i.summary ?? "").toLowerCase().includes(q)) return false;
if (f.assignee === NONE ? i.assignee !== null : f.assignee !== "" && i.assignee !== f.assignee) return false;
if (f.epic === NONE ? i.epic_key !== null : f.epic !== "" && i.epic_key !== f.epic) return false;
if (f.hideDone && i.done) return false;
return true;
}
export function hasFilters(f: IssueFilter): boolean {
return f.q.trim() !== "" || f.assignee !== "" || f.epic !== "" || f.hideDone;
}
export type Opt = { value: string; label: string };
// «все», «Без исполнителя», затем исполнители по алфавиту; current, которого нет в данных, — в конце (значение не теряется)
export function assigneeOptions(issues: AgileIssue[], current: string): Opt[] {
const names = [...new Set(issues.map((i) => i.assignee).filter((a): a is string => !!a))]
.sort((a, b) => a.localeCompare(b, "ru"));
const opts: Opt[] = [
{ value: "", label: "все" },
{ value: NONE, label: "Без исполнителя" },
...names.map((n) => ({ value: n, label: n })),
];
if (current && !opts.some((o) => o.value === current)) opts.push({ value: current, label: current });
return opts;
}
export interface EpicEntry { key: string; title: string; count: number; done: boolean }
// порядок по rank (null в конце), затем по key
export function byRank(a: AgileIssue, b: AgileIssue): number {
if (a.rank === null && b.rank !== null) return 1;
if (a.rank !== null && b.rank === null) return -1;
if (a.rank !== null && b.rank !== null && a.rank !== b.rank) return a.rank < b.rank ? -1 : 1;
return a.key < b.key ? -1 : a.key > b.key ? 1 : 0;
}
// первое вхождение каждого key
const uniqByKey = (list: AgileIssue[]): AgileIssue[] => {
const seen = new Set<string>();
return list.filter((i) => {
if (seen.has(i.key)) return false;
seen.add(i.key);
return true;
});
};
// объединение задач спринтов без повторов по key, по rank
export function sprintIssues(sprints: AgileSprint[]): AgileIssue[] {
return uniqByKey(sprints.flatMap((s) => s.issues)).sort(byRank);
}
// активные (в порядке входа), затем будущие по дате начала; без даты — в конце; закрытые не выбираются
export function selectableSprints(sprints: AgileSprint[]): AgileSprint[] {
const active = sprints.filter((s) => s.state === "active");
const future = sprints.filter((s) => s.state === "future");
const startMs = (s: AgileSprint) => jiraDate(s.start_date)?.getTime() ?? Infinity;
return [...active, ...future.sort((a, b) => startMs(a) - startMs(b))];
}
// id из «101,102» среди selectableSprints, в их порядке; недопустимые отбрасываются; если не осталось ни одного — все активные
export function parseSprintIds(param: string | null, sprints: AgileSprint[]): number[] {
const selectable = selectableSprints(sprints);
const ids = new Set(
(param ?? "").split(",").map((x) => x.trim()).filter((x) => /^\d+$/.test(x)).map(Number),
);
const picked = selectable.filter((s) => ids.has(s.id)).map((s) => s.id);
if (picked.length > 0) return picked;
return selectable.filter((s) => s.state === "active").map((s) => s.id);
}
// строки-эпики из всех спринтов и бэклога (по rank, без повторов), затем ключи из epic_key без строки (по ключу);
// count — задачи-не-эпики с этим epic_key в активных, будущих спринтах и бэклоге, без повторов по key
export function epicEntries(d: AgileResponse): EpicEntry[] {
const backlog = d.backlog ?? [];
const live = [...d.sprints.filter((s) => s.state !== "closed").flatMap((s) => s.issues), ...backlog];
const liveTasks = uniqByKey(live.filter((i) => !isEpic(i)));
const rows = uniqByKey([...d.sprints.flatMap((s) => s.issues), ...backlog].filter(isEpic)).sort(byRank);
const count = (key: string) => liveTasks.filter((t) => t.epic_key === key).length;
const entries: EpicEntry[] = rows.map((e) => ({ key: e.key, title: e.summary ?? e.key, count: count(e.key), done: e.done }));
const known = new Set(rows.map((e) => e.key));
const missing = [...new Set(liveTasks.map((t) => t.epic_key).filter((k): k is string => !!k && !known.has(k)))].sort();
return [...entries, ...missing.map((key) => ({ key, title: key, count: count(key), done: false }))];
}

View File

@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import type { AgileBoard, AgileResponse } from "../api/types.ts";
import { boardState, daysLeft, fmtDay, fmtRange, fmtSp, jiraDate, tasksWord } from "./model.ts";
import { boardState, daysLeft, fmtDay, fmtRange, fmtSp, isEpic, jiraDate, tasksWord, typeKind } from "./model.ts";
const resp = (over: Partial<AgileBoard>): AgileResponse => ({
board: { id: 4216, type: "scrum", columns: [{ name: "Готово", statuses: ["Готово"] }], kanban_backlog: null, fetched_at: "2026-10-24T10:00:00+00:00", ...over },
@ -29,6 +29,23 @@ describe("даты и числа экранов доски", () => {
expect([fmtSp(null), fmtSp(3), fmtSp(2.5)]).toEqual(["—", "3", "2.5"]);
expect([1, 2, 5, 11, 21, 73].map(tasksWord)).toEqual(["задача", "задачи", "задач", "задач", "задача", "задачи"]);
});
it("fmtSp — до двух знаков без хвостов (M-6)", () => {
expect([fmtSp(0.1 + 0.2), fmtSp(1 / 3), fmtSp(0)]).toEqual(["0.3", "0.33", "0"]);
});
it("typeKind — имена типов Jira, без учёта регистра", () => {
const kinds = (types: (string | null)[]) => types.map(typeKind);
expect(kinds(["Эпик", "epic", "Фича"])).toEqual(["epic", "epic", "epic"]);
expect(kinds(["История", "Story"])).toEqual(["story", "story"]);
expect(kinds(["Задача", "Task"])).toEqual(["task", "task"]);
expect(kinds(["Ошибка", "BUG"])).toEqual(["bug", "bug"]);
expect(kinds(["Подзадача", "Sub-task", "Subtask"])).toEqual(["subtask", "subtask", "subtask"]);
expect(kinds([null, "", "Риск"])).toEqual(["other", "other", "other"]);
});
it("isEpic — по виду типа", () => {
expect(isEpic({ issue_type: "Фича" })).toBe(true);
expect(isEpic({ issue_type: null })).toBe(false);
expect(isEpic({ issue_type: "История" })).toBe(false);
});
it("boardState — три состояния (design #23)", () => {
expect(boardState({ board: null, sprints: [], backlog: null, board_issues: null })).toBe("no-data");
expect(boardState(resp({ fetched_at: null, columns: null }))).toBe("no-data");

View File

@ -22,7 +22,23 @@ export function daysLeft(endIso: string | null, now: Date): number | null {
return Math.round((midnight(end) - midnight(now)) / 86_400_000);
}
export const fmtSp = (n: number | null): string => (n === null ? "—" : String(n));
// очки до двух знаков без хвостов: 0.1+0.2 → «0.3», 1/3 → «0.33» (M-6)
export const fmtSp = (n: number | null): string => (n === null ? "—" : String(Number(n.toFixed(2))));
// вид типа задачи для значка и логики (имена Jira, без учёта регистра); прочее и пустое — other
export type TypeKind = "epic" | "story" | "task" | "bug" | "subtask" | "other";
const TYPE_KINDS: [RegExp, TypeKind][] = [
[/^(Эпик|Epic|Фича)$/i, "epic"],
[/^(История|Story)$/i, "story"],
[/^(Задача|Task)$/i, "task"],
[/^(Ошибка|Bug)$/i, "bug"],
[/^(Подзадача|Sub-task|Subtask)$/i, "subtask"],
];
export function typeKind(type: string | null): TypeKind {
const name = (type ?? "").trim();
return TYPE_KINDS.find(([re]) => re.test(name))?.[1] ?? "other";
}
export const isEpic = (i: { issue_type: string | null }): boolean => typeKind(i.issue_type) === "epic";
// «1 задача», «2 задачи», «5 задач» (R-24)
export function tasksWord(n: number): string {

View File

@ -0,0 +1,44 @@
// состояние фильтров страницы доски: вид (хранится по метке и странице) + поиск q (только в памяти)
import { useCallback, useMemo, useRef, useState } from "react";
import { readView, saveView } from "./agilePref.ts";
import type { AgilePage, AgileView, IssueFilter } from "./filters.ts";
export function useAgileView(label: string, page: AgilePage) {
const id = `${label}\u0000${page}`;
const [state, setState] = useState(() => ({ id, view: readView(label, page), q: "" }));
// метка или страница сменились — читаем вид заново, поиск сбрасывается
let cur = state;
if (state.id !== id) {
cur = { id, view: readView(label, page), q: "" };
setState(cur);
}
const ref = useRef(cur);
// ref обновляется при рендере: set/reset в одном событии должны видеть актуальный вид, а не замыкание прошлого рендера
ref.current = cur;
const commit = useCallback((next: typeof state) => {
ref.current = next;
setState(next);
}, []);
const set = useCallback((patch: Partial<AgileView>) => {
const next = { ...ref.current, view: { ...ref.current.view, ...patch } };
saveView(label, page, next.view); // запись вне функции-обновителя setState (T8.1 #69)
commit(next);
}, [label, page, commit]);
const setQ = useCallback((q: string) => commit({ ...ref.current, q }), [commit]);
const reset = useCallback(() => {
const next = { ...ref.current, q: "", view: { ...ref.current.view, assignee: "", epic: "", hideDone: false } };
saveView(label, page, next.view);
commit(next);
}, [label, page, commit]);
const { view, q } = cur;
const filter: IssueFilter = useMemo(
() => ({ q, assignee: view.assignee, epic: view.epic, hideDone: view.hideDone }),
[q, view.assignee, view.epic, view.hideDone],
);
return { view, set, q, setQ, filter, reset };
}

View File

@ -1,7 +1,7 @@
import type { ReactNode } from "react";
// иконки дизайн-системы: React-SVG 24x24, штрих currentColor 1.5; без dangerouslySetInnerHTML
export type IconProps = { size?: 16 | 20 };
export type IconProps = { size?: 12 | 16 | 20 };
function make(children: ReactNode) {
return ({ size = 20 }: IconProps) => (
@ -47,3 +47,7 @@ export const IconBook = make(<><path d="M5 4.5A1.5 1.5 0 0 1 6.5 3H19v15H6.5A1.5
export const IconSprint = make(<><circle cx="12" cy="12" r="8"/><path d="M12 7.5V12l3 2"/></>);
export const IconBacklog = make(<><rect x="4" y="4" width="16" height="4.5" rx="1.5"/><rect x="4" y="10" width="16" height="4.5" rx="1.5"/><rect x="4" y="16" width="10" height="4" rx="1.5"/></>);
export const IconKanban = make(<><rect x="4" y="4" width="4.5" height="16" rx="1.5"/><rect x="9.75" y="4" width="4.5" height="10" rx="1.5"/><rect x="15.5" y="4" width="4.5" height="13" rx="1.5"/></>);
export const IconBookmark = make(<><path d="M6.5 4h11v16.5L12 16.5 6.5 20.5z"/></>);
export const IconCircle = make(<><circle cx="12" cy="12" r="8"/></>);
export const IconSquares = make(<><rect x="4" y="4" width="7" height="7" rx="1.5"/><rect x="13" y="13" width="7" height="7" rx="1.5"/></>);
export const IconDot = make(<><circle cx="12" cy="12" r="2.5" fill="currentColor"/></>);