sdlc-hub/docs/superpowers/plans/2026-10-25-web-bugfixes.md
Andrey Epifancev ba1db6568c План: пакет веб-багов web-bugfixes (#124), задачи 1–8
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 22:52:35 +04:00

506 lines
35 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Пакет веб-багов (web-bugfixes) Implementation Plan
> **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:** убрать видимые пользователю ошибки веба хаба из пакета A сверки (sdlc-hub#124), каждую закрепив тестом, который до правки падает.
**Architecture:** только клиент (`web/src/`): точечные правки окна карточки, разборщика вики-разметки, трекера
состояния модулей, вложений, доски и UI-кита. Одна новая чистая функция `wikiPlain` для текста ленты. API, схема, MCP и
агенты не меняются.
**Tech Stack:** React 19 + TypeScript + Vite, React Router (data router), TanStack Query, Vitest + Testing Library, Playwright.
**Spec:** `openspec/changes/web-bugfixes/` — `design.md` (решения 1–21, ссылки на строки main 1405ccd),
`specs/web-app/spec.md` (дельта требований и сценарии), `proposal.md`.
## Global Constraints
- Меняется только `web/src/` (+ `web/e2e/issues.spec.ts`). Сервер, API, MCP, агенты — без изменений.
- Язык комментариев и сообщений коммитов — русский; идентификаторы — английские. Плотность комментариев — как в окружающем коде.
- Разметка MUST NOT превращаться в HTML строкой (`innerHTML`, `dangerouslySetInnerHTML`).
- Тесты — рядом с кодом (`*.test.ts(x)`), запросы к хабу мокаются `mockFetch`/`json` из `web/src/test/fetchMock.ts`.
- Каждая задача начинается с теста, который падает до правки (RED подтверждается запуском и фиксируется в отчёте).
- Тексты интерфейса — дословно из спеки: «Вид работы в Jira», «Назад», «Закрыть», «Остаться», «Уйти без сохранения
заметки?», «Повторить», «Загрузка…», «хаб не отвечает», «нет в зеркале», «Задачи {key} нет в зеркале хаба.».
- Коммит в конце каждой задачи; сообщение заканчивается строкой `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.
- Зелёным считается только весь набор: финально `make check` из корня репозитория и `cd web && npm run e2e`.
## Review Focus
1. Ctrl/Cmd/средний щелчок по ключу в списке «Задачи» по-прежнему открывает карточку в новой вкладке (перехват только
обычного щелчка) — тест в Task 4.
2. Окно, открытое прямым адресом (`depth = 0`), и двойной ✕: адрес — список, дальше по истории не уходит — тест в Task 4.
3. `wikiPlain` на незакрытой разметке, неизвестных макросах и пустом тексте не бросает и отдаёт текст как есть; пустой
комментарий в ленте — по-прежнему «комментарий: —» — тест в Task 2.
4. Настройки ещё не пришли или без `kind_labels`: все метки — в «Метках», строки «Вид работы в Jira» нет — тест в Task 3.
5. Смена проекта A → B → A во время опроса: пакет A снова подхватывается при возврате (сброс не «запирает» модуль) — тест в Task 5.
---
### Task 1: Вики-разметка — краевые случаи (#56 M-5, M-6)
**Files:**
- Modify: `web/src/modules/jira/issues/wiki/inline.ts` (`ESCAPABLE` :21, `bracket` :148-152)
- Modify: `web/src/modules/jira/issues/wiki/parse.ts` (`MACRO` :17, `closeMacro` :103-116, `row` :118-139)
- Test: `web/src/modules/jira/issues/wiki/inline.test.ts`, `web/src/modules/jira/issues/wiki/parse.test.ts`
**Interfaces:**
- Consumes: —
- Produces: прежние `parseWiki(text, opts): Block[]`, `parseInline(line, opts, label?): Inline[]` с исправленным разбором.
- [ ] **Step 1: Write the failing tests**
В `inline.test.ts`:
```ts
it("\\# — символ решётки (M-5)", () => {
expect(parseInline("\\# не пункт")).toEqual([{ t: "text", text: "# не пункт" }]);
});
it("[~логин|текст] — упоминание логина без подписи (M-6)", () => {
expect(parseInline("[~ivanov|Иванов]")).toEqual([{ t: "mention", login: "ivanov" }]);
});
```
В `parse.test.ts`:
```ts
it("\\# в начале строки — абзац, не список", () => {
expect(parseWiki("\\# не пункт")).toEqual([{ t: "p", children: [{ t: "text", text: "# не пункт" }] }]);
});
it("quote и noformat с параметрами (M-5)", () => {
expect(parseWiki("{quote:title=Итог}\nсогласовано\n{quote}")).toEqual([{ t: "quote", children: [{ t: "p", children: [{ t: "text", text: "согласовано" }] }] }]);
expect(parseWiki("{noformat:borderStyle=solid}\n*x*\n{noformat}")).toEqual([{ t: "pre", text: "*x*" }]);
});
it("пробелы после открывающего {code} не дают пустой строки (M-6)", () => {
expect(parseWiki("{code} \nx = 1\n{code}")).toEqual([{ t: "pre", text: "x = 1" }]);
});
it("непарная [ в ячейке не съедает разделители (M-6)", () => {
const [table] = parseWiki("|[draft|итог|готово|");
expect(table).toMatchObject({ t: "table" });
expect((table as { rows: { children: { text: string }[] }[][] }).rows[0].map((c) => c.children[0].text)).toEqual(["[draft", "итог", "готово"]);
});
it("| внутри [текст|адрес] ячейку по-прежнему не делит", () => {
const [table] = parseWiki("|[док|https://x.ru]|b|");
expect((table as { rows: unknown[][] }).rows[0]).toHaveLength(2);
});
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `cd web && npx vitest run src/modules/jira/issues/wiki/`
Expected: FAIL — новые тесты (кроме последнего, он фиксирует текущее поведение и должен быть зелёным).
- [ ] **Step 3: Implement**
- `ESCAPABLE` — добавить `#`.
- `MACRO = /^\s*\{(code|noformat|quote)(?::[^}]*)?\}/`; имя тега в `closeMacro` — первая группа (без параметров), закрывающий тег — `{имя}`.
- `closeMacro`: остаток строки открывающего тега, состоящий из одних пробелов, в `body` не попадает (`text.trim()` вместо `text` в условии для `k === start`).
- `bracket`: при `body.startsWith("~")` логин — `body.slice(1).split("|")[0].trim()`; пустой или с пробелом — `null`, как сейчас.
- `row`: `[` увеличивает `depth`, только если в `s` правее позиции есть `]` (`s.indexOf("]", j + 1) >= 0`).
- [ ] **Step 4: Run tests to verify they pass**
Run: `cd web && npx vitest run src/modules/jira/issues/wiki/`
Expected: PASS (все тесты каталога, включая прежние).
- [ ] **Step 5: Commit**
```bash
git add web/src/modules/jira/issues/wiki/
git commit -m "Вики-разметка: \\#, макросы с параметрами, [~логин|текст], пробелы после {code}, непарная [ в таблице (#124, #56 M-5, M-6)"
```
---
### Task 2: Текст комментария в ленте без разметки (#103 С6)
**Files:**
- Create: `web/src/modules/jira/issues/wiki/plain.ts`
- Modify: `web/src/modules/jira/feed/events.ts` (`eventText`, ветка `comment` :72-75)
- Test: `web/src/modules/jira/issues/wiki/plain.test.ts` (новый), `web/src/modules/jira/feed/events.test.ts`
**Interfaces:**
- Consumes: `parseWiki`, `Block`, `Inline`, `WikiOptions` из Task 1 (`parse.ts`, `inline.ts`).
- Produces: `export function wikiPlain(text: string, opts?: WikiOptions): string` — текст как читается отрендеренным,
блоки через пробел (не одна строка — «одна строка» и обрезку делает `eventText`).
- [ ] **Step 1: Write the failing tests**
`plain.test.ts`:
```ts
import { wikiPlain } from "./plain.ts";
it("выделение, ссылки, упоминание, заголовок", () => {
expect(wikiPlain("h3. Итог\n*Готово*, см. [протокол|https://x.ru/p] и [~ivanov]").replace(/\s+/g, " ").trim())
.toBe("Итог Готово, см. протокол и @ivanov");
});
it("[адрес] — сам адрес, {{моно}} и {code} — содержимое, ---- — пусто", () => {
expect(wikiPlain("[https://x.ru] {{a_b}}\n----\n{code}\nx = 1\n{code}").replace(/\s+/g, " ").trim()).toBe("https://x.ru a_b x = 1");
});
it("ключ задачи и вложение — текстом", () => {
const opts = { projectKey: "AURORA", attachments: [{ id: "1", filename: "скрин.png", created: null }] };
expect(wikiPlain("см. AURORA-1 и !скрин.png!", opts).trim()).toBe("см. AURORA-1 и скрин.png");
});
it("незакрытая и неизвестная разметка — как есть, пустой текст — пусто (Review Focus 3)", () => {
expect(wikiPlain("{color:red}важно *без конца").trim()).toBe("{color:red}важно *без конца");
expect(wikiPlain("")).toBe("");
});
```
В `events.test.ts`:
```ts
it("комментарий в ленте — без вики-разметки (С6)", () => {
expect(eventText({ ...commentEvent, to: "h3. Итог\n*Готово*, см. [протокол|https://x.ru/p] и [~ivanov]" }))
.toBe("комментарий: Итог Готово, см. протокол и @ivanov");
});
it("пустой комментарий — по-прежнему «—»", () => {
expect(eventText({ ...commentEvent, to: " " })).toBe("комментарий: —");
});
```
(`commentEvent` — событие `kind: "comment"` по образцу уже имеющихся в `events.test.ts`.)
- [ ] **Step 2: Run tests to verify they fail**
Run: `cd web && npx vitest run src/modules/jira/issues/wiki/plain.test.ts src/modules/jira/feed/events.test.ts`
Expected: FAIL — нет модуля `plain.ts`; тест С6 видит сырую разметку.
- [ ] **Step 3: Implement**
`wikiPlain` обходит дерево `parseWiki`: `text` — текст; метки выделения — дети; `code` — текст; `link` — дети
(у `[адрес]` дети уже содержат адрес); `issue` — `key`, если детей нет, иначе дети; `attachment` — дети; `mention` —
`"@" + login`; `br` — пробел; блоки (`heading`, `p`, `list` с пунктами и вложенными списками, `table` по ячейкам,
`pre` — `text`, `quote` — вложенные блоки, `hr` — пусто) соединяются пробелом. В `eventText` ветка `comment`:
`oneLine(wikiPlain(e.to ?? ""))`, остальное (обрезка 300 и «…», «—» для пустого) — как сейчас.
- [ ] **Step 4: Run tests to verify they pass**
Run: `cd web && npx vitest run src/modules/jira/issues/wiki/ src/modules/jira/feed/`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add web/src/modules/jira/issues/wiki/plain.ts web/src/modules/jira/issues/wiki/plain.test.ts web/src/modules/jira/feed/
git commit -m "Лента: текст комментария без вики-разметки Jira (#124, #103 С6)"
```
---
### Task 3: Карточка — связи «нет в зеркале», окно 404, метки видов, пустое описание (#42 M-5, #103, #56 M-4)
**Files:**
- Modify: `web/src/modules/jira/issues/card.ts` (новая функция)
- Modify: `web/src/modules/jira/issues/IssueCard.tsx` (404/ошибка :34-49, `Links` :110-134, описание :79, `OtherFields` :225-237)
- Modify: `web/src/modules/jira/issues/overlay.tsx` (передать в карточку `onBack`, если `card.depth >= 1` … см. Step 3)
- Test: `web/src/modules/jira/issues/card.test.ts` (если нет — создать), `web/src/modules/jira/issues/IssueCard.test.tsx`, `web/src/modules/jira/issues/IssueModal.test.tsx`
**Interfaces:**
- Consumes: `JIRA_URL` (`../board/constants.ts`), `settings.data.kind_labels` (вид → метка).
- Produces:
- `export function splitKindLabels(labels: string[], kindLabels: Record<string, string> | undefined): { kinds: string[]; rest: string[] }` в `card.ts` — `kinds` — названия видов в порядке меток задачи, `rest` — прочие метки;
- `IssueCard` получает необязательный проп `onBack?: () => void`; `IssueModal` прокидывает его из `useIssueOverlay`.
- [ ] **Step 1: Write the failing tests**
`card.test.ts`:
```ts
it("метки видов работ отдельно от прочих", () => {
expect(splitKindLabels(["AWPCA", "kind-dev", "kind-ui"], { разработка: "kind-dev", UI: "kind-ui" }))
.toEqual({ kinds: ["разработка", "UI"], rest: ["AWPCA"] });
});
it("без kind_labels — все метки прочие (Review Focus 4)", () => {
expect(splitKindLabels(["AWPCA", "kind-dev"], undefined)).toEqual({ kinds: [], rest: ["AWPCA", "kind-dev"] });
});
```
`IssueCard.test.tsx`:
- «Прочие поля»: задача с метками `["AWPCA", "kind-dev"]`, настройки фикстуры `settings.json` → видны пары «Вид работы в Jira» / «разработка» и «Метки» / «AWPCA», текста `kind-dev` в карточке нет.
- описание `" \n\n"` → нет заголовка «Описание».
- связь на ключ вне зеркала (`summary: null, status: null`) → в «Связях» `getByRole("link", { name: /AURORA-1999/ })` имеет `href` = `${JIRA_URL}/browse/AURORA-1999` и `target="_blank"`; рядом текст «нет в зеркале».
`IssueModal.test.tsx` (сценарий спеки «Задача не в зеркале после перехода»): из списка открыта AURORA-9002, в окне
переход по ссылке на ключ, для которого хаб отвечает 404 (`json(404, {})` в `hub()` для этого ключа) → в окне «Задачи
AURORA-1999 нет в зеркале хаба.» и кнопки «Назад» и «Закрыть»; «Назад» — заголовок окна снова AURORA-9002; при новом
переходе и «Закрыть» — окна нет, адрес — список. Прямой адрес `/app/jira/issues/AURORA-1999` (404) → только «Закрыть».
- [ ] **Step 2: Run tests to verify they fail**
Run: `cd web && npx vitest run src/modules/jira/issues/card.test.ts src/modules/jira/issues/IssueCard.test.tsx src/modules/jira/issues/IssueModal.test.tsx`
Expected: FAIL — нет `splitKindLabels`, метка `kind-dev` видна в «Метках», рамка описания есть, ссылка ведёт на `/jira/issues/…`, кнопки «Назад» нет.
- [ ] **Step 3: Implement**
- `OtherFields`: строки «Вид работы в Jira» (`kinds.join(", ")`) и «Метки» (`rest.join(", ")`) вместо прежней «Метки»;
`kind_labels` — из `useSettings()` (передать пропом из `IssueCard`). Положение «Вид работы в Jira» — сразу перед «Метками».
- Описание: условие `i.description?.trim()`.
- `Links`: при `l.summary === null && l.status === null` ключ — `<a href={`${JIRA_URL}/browse/${l.key}`} target="_blank" rel="noopener">` со значком `IconExternal` (как «Открыть в Jira»), иначе — `IssueLink`, как сейчас.
- `useIssueOverlay`: «Назад» — только если окно открыто переходом из другой карточки. По `openCardState`
(`modal.ts:25-28`): из списка `depth = 1, direct = false`; прямой адрес — `depth = 0, direct = true`; каждый переход
внутри окна — `depth + 1`. Отсюда `onBack = card && (card.depth >= 2 || (card.direct && card.depth >= 1)) ? () => void
navigate(-1) : undefined`; прокинуть через `IssueModal` в `IssueCard`. Тесты: «из списка — без Назад», «после перехода —
с Назад», «прямой адрес — без Назад», «переход из прямого адреса — с Назад».
- 404 и ошибка загрузки: `action` — «Назад» (если `onBack`) и «Закрыть» / «Повторить», «Назад» (если есть), «Закрыть».
- [ ] **Step 4: Run tests to verify they pass**
Run: `cd web && npx vitest run src/modules/jira/issues/`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add web/src/modules/jira/issues/
git commit -m "Карточка: связь вне зеркала ведёт в Jira, «Назад» у окна 404, метки видов работ отдельно, пустое описание (#124, #42 M-5, #103, #56 M-4)"
```
---
### Task 4: Окно карточки — поиск, двойное закрытие, подтверждение ухода (#42 M-1, M-2, M-4)
**Files:**
- Modify: `web/src/modules/jira/issues/IssuesPage.tsx` (:14-38, `open` :50)
- Modify: `web/src/modules/jira/issues/IssueTable.tsx` (ключ :38 — обычный щелчок через `onOpen`)
- Modify: `web/src/modules/jira/issues/overlay.tsx` (`close` :37-41)
- Modify: `web/src/modules/jira/issues/NoteForm.tsx` (подтверждение ухода :82-89, эффект :42)
- Test: `web/src/modules/jira/issues/IssueModal.test.tsx`, `web/src/modules/jira/issues/IssuesPage.test.tsx`, `web/e2e/issues.spec.ts`
**Interfaces:**
- Consumes: `openCardState`, `cardPath` (`modal.ts`, `IssueLink.tsx`).
- Produces: `IssueTable` сохраняет проп `onOpen(key: string): void` — теперь его зовут и строка, и обычный щелчок по ключу.
- [ ] **Step 1: Write the failing tests**
`IssueModal.test.tsx`:
- «поиск перед открытием не закрывает окно» (сценарий спеки): список, в поле поиска (`getByRole("searchbox")`)
набрано «свод», сразу щёлкнут ключ AURORA-9002 (без ожидания 300 мс) → окно открыто; после `await new Promise(r => setTimeout(r, 400))`
окно всё ещё открыто (`screen.getByRole("dialog")`), `router.state.location.pathname` — `/app/jira/issues/AURORA-9002`;
после ✕ адрес — `/app/jira/issues` с `q=свод`. То же для щелчка по ячейке темы строки.
- «двойной щелчок по ✕ не уводит дальше назад»: история `["/app/jira/links", "/app/jira/issues"]`, открыта AURORA-9002,
переход по связи на AURORA-9001, `await userEvent.dblClick(✕)` → адрес — `/app/jira/issues`, не `/app/jira/links`.
- то же для прямого адреса (Review Focus 2): история `["/app/jira/issues/AURORA-9002"]`, двойной ✕ → `/app/jira/issues`.
- подтверждение ухода: в форме заметки правка, нажата Esc → `getByRole("alertdialog", { name: "Уйти без сохранения заметки?" })`,
`document.activeElement` — кнопка «Остаться».
`IssuesPage.test.tsx` (Review Focus 1): у ключа строки `href` прежний (`/jira/issues/AURORA-9001`), щелчок с `ctrlKey`
(`fireEvent.click(link, { ctrlKey: true })`) не вызывает навигацию внутри приложения (адрес страницы не меняется).
`web/e2e/issues.spec.ts`: сценарий «поиск перед открытием карточки» — ввести текст в поиск и сразу щёлкнуть первую
строку; через 600 мс окно открыто; Escape — адрес списка содержит `q=`.
- [ ] **Step 2: Run tests to verify they fail**
Run: `cd web && npx vitest run src/modules/jira/issues/IssueModal.test.tsx src/modules/jira/issues/IssuesPage.test.tsx`
Expected: FAIL — окно закрывается через 300 мс; двойной ✕ уходит на `/app/jira/links`; у подтверждения нет имени и фокуса.
- [ ] **Step 3: Implement**
- `IssuesPage`: функция `openCard(key)` — снять таймер поиска; если `q` отличается от `filters.q`, записать `q` в адрес
(`replace`) и открыть карточку с фоном, у которого `search` уже содержит `q` (`navigate(cardPath(key), { state:
openCardState({ ...loc, search: <поиск с q> }) })`, `written.current = q`). Её получает `IssueTable` как `onOpen`.
Таймер не перезаводится, пока страница под окном (адрес `filters.q` совпадает с `q` после записи — эффект ничего не делает).
- `IssueTable`: ключ — та же ссылка `IssueLink` (адрес и состояние для новой вкладки), но обычный щелчок (левая кнопка
без `ctrl`/`meta`/`shift`/`alt`) — `preventDefault()` и `onOpen(key)`.
- `overlay.tsx`: `closingKey = useRef<string | null>(null)`; `close` выходит, если `closingKey.current === location.key`,
иначе запоминает `location.key` и закрывает как сейчас. Сброс — эффект по смене `location.key`.
- `NoteForm`: у блока подтверждения ухода `aria-label="Уйти без сохранения заметки?"`; ref на кнопку «Остаться»; при
`blocker.state === "blocked"` — `scrollIntoView` (как сейчас) и `focus()` этой кнопки.
- [ ] **Step 4: Run tests to verify they pass**
Run: `cd web && npx vitest run src/modules/jira/issues/ && npm run e2e -- issues.spec.ts`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add web/src/modules/jira/issues/ web/e2e/issues.spec.ts
git commit -m "Окно карточки: поиск не закрывает окно, повторное закрытие игнорируется, фокус подтверждения ухода (#124, #42 M-1, M-2, M-4)"
```
---
### Task 5: Строка состояния — ответ опроса прежнего проекта (#116)
**Files:**
- Modify: `web/src/shell/activity/ActivityProvider.tsx` (эффект опроса :126-154)
- Test: `web/src/shell/activity/activity.test.tsx`
**Interfaces:**
- Consumes: —
- Produces: поведение `ModuleActivity` без изменений интерфейса.
- [ ] **Step 1: Write the failing test**
В `activity.test.tsx` — детерминированный тест «ответ опроса после смены проекта не возвращает событие»: ответ
`GET /batches/9` (пакет `wiki_sync` проекта AWPCA) задерживается промисом-заглушкой (`deferred` из `web/src/test/batchHub.ts`);
когда первый ответ показан (`event.kind === "batch"`), следующий ответ держится; в одном `act` вызвать
`m.setProject("STOCKS_DRRK")` и разрешить задержанный ответ → после `await waitFor(...)` и `sleep(50)` `m.now().wiki.event`
равно `null`. Исполнитель подбирает порядок так, чтобы тест падал на текущем коде (RED обязателен, см. Risks в design.md);
если воспроизвести в одном такте не удаётся — описать в отчёте, как получен RED (например, `pollMs` 0 и разрешение
ответа синхронно перед `setProject`).
Второй тест (Review Focus 5): A → B → A — после возврата на AWPCA незавершённый пакет 9 снова подхватывается
(`event` `{ kind: "batch", id: 9 }`).
- [ ] **Step 2: Run tests to verify they fail**
Run: `cd web && npx vitest run src/shell/activity/activity.test.tsx`
Expected: FAIL — первый тест: событие `batch` id 9 после смены проекта.
- [ ] **Step 3: Implement**
В начале эффекта опроса: `if (!active || activeRef.current !== active) return;` — ответ обрабатывается, только если
отслеживаемый пакет тот же объект, что в `activeRef` (смена проекта синхронно обнуляет `activeRef` в `setActiveNow`).
Проверить, что `setActiveNow({ ...active, own: true })` (:138) и `track`/`start` по-прежнему проходят: они сами
обновляют `activeRef`.
- [ ] **Step 4: Run tests to verify they pass**
Run: `cd web && npx vitest run src/shell/activity/activity.test.tsx` и прогон в цикле:
`for i in $(seq 20); do npx vitest run src/shell/activity/activity.test.tsx --silent || break; done`
Expected: PASS все 20 прогонов.
- [ ] **Step 5: Commit**
```bash
git add web/src/shell/activity/
git commit -m "Состояние модулей: ответ опроса прежнего проекта не возвращает событие (#124, #116)"
```
---
### Task 6: Список задач ждёт настройки (#60 M-3)
**Files:**
- Modify: `web/src/modules/jira/issues/IssuesPage.tsx` (запрос :45-48, «Загрузка…» :60-61)
- Modify: `web/src/modules/jira/api/queries.ts` (`useIssues` — необязательный параметр `enabled`)
- Test: `web/src/modules/jira/issues/IssuesPage.test.tsx`
**Interfaces:**
- Consumes: `useSettings()` (`isPending`).
- Produces: `useIssues(filters: IssueFilters, opts?: { enabled?: boolean })`.
- [ ] **Step 1: Write the failing test**
«закрытый статус в адресе до настроек» (сценарий спеки): ответ `/settings` задержан; маршрут `/issues?status=Готово`;
пока настройки не пришли — `issuesCalls(fetch)` пуст, видно «Загрузка…», нет «По этим фильтрам задач нет»; после
ответа `/settings` (с «Готово» в `closed_statuses`) — ровно один вызов `/issues` с `status=Готово` и без `hide_closed`.
Второй тест (R-11): `/settings` отвечает 500 → один вызов `/issues` с `hide_closed=1`.
- [ ] **Step 2: Run test to verify it fails**
Run: `cd web && npx vitest run src/modules/jira/issues/IssuesPage.test.tsx`
Expected: FAIL — первый вызов `/issues` уходит до настроек с `hide_closed=1`.
- [ ] **Step 3: Implement**
`useIssues(query, { enabled: !settings.isPending })`; пока настройки грузятся, «Загрузка…» (условие подсказки — по
`list.isPending || settings.isPending`), сообщение «По этим фильтрам задач нет» — только после ответа списка.
- [ ] **Step 4: Run tests to verify they pass**
Run: `cd web && npx vitest run src/modules/jira/issues/`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add web/src/modules/jira/
git commit -m "Список задач: запрос после настроек, без мелькания «задач нет» (#124, #60 M-3)"
```
---
### Task 7: Вложения — прежняя ошибка, 401 при опросе, порядок хаба (#86 T6.2, T6.3, M-4)
**Files:**
- Modify: `web/src/modules/jira/issues/attachments/useAttachmentDownload.ts`
- Modify: `web/src/modules/jira/issues/attachments/AttachmentList.tsx` (`newestFirst` :14-17, ошибка :36)
- Modify: `web/src/modules/jira/issues/wiki/inline.ts` (`findAttachment` :36-45)
- Test: `web/src/modules/jira/issues/attachments/useAttachmentDownload.test.tsx`, `web/src/modules/jira/issues/IssueCard.test.tsx`, `web/src/modules/jira/issues/wiki/inline.test.ts`
**Interfaces:**
- Consumes: `dropTokenOn`, `AuthError`, `HubDownError` (`web/src/api/client.ts`).
- Produces: `DownloadStatus` дополняется полем `ok: boolean` (успех в этой карточке); `IDLE.ok = false`.
- [ ] **Step 1: Write the failing tests**
- `useAttachmentDownload.test.tsx`: опрос `GET /attachments/{id}` отвечает 401 → вызван сброс токена (токен в хранилище
пуст / экран входа по образцу других тестов `AuthError`), текста ошибки нет; ответ — сеть недоступна (`HubDownError`)
→ `status(id).error === "хаб не отвечает"`.
- `IssueCard.test.tsx` (сценарий «Повтор после ошибки»): вложение с `state: "failed", error: "сбой"`, «Повторить», хаб
отдаёт `ready` и файл, перечитанная карточка всё ещё отдаёт `failed` (задержать) → текста «сбой» нет.
- `IssueCard.test.tsx`: вложения в ответе хаба в порядке `[b (created null), a (created 2026-10-20)]` → в разделе в том же порядке.
- `inline.test.ts`: два вложения «скрин.png» — `[{id: "5", created: null}, {id: "9", created: "2026-10-20T10:00:00.000+0300"}]` →
`!скрин.png!` даёт `attachment` с `id: "5"` (первое в порядке хаба).
- [ ] **Step 2: Run tests to verify they fail**
Run: `cd web && npx vitest run src/modules/jira/issues/attachments/ src/modules/jira/issues/IssueCard.test.tsx src/modules/jira/issues/wiki/inline.test.ts`
Expected: FAIL.
- [ ] **Step 3: Implement**
- В `useAttachmentDownload`: общая функция разбора ошибки — `AuthError` → `dropTokenOn(e)` без текста; `HubDownError` →
«хаб не отвечает»; прочие — `errorText`. Успех — `put(id, { busy: false, error: null, ok: true })`; `start` сбрасывает `ok`.
- `AttachmentList`: порядок — как в `items`; серверный `failed` показывается, только если `!ok`. Убрать `newestFirst`
(и его экспорт, если других потребителей нет).
- `findAttachment`: первое вложение с этим именем в порядке `opts.attachments`.
- [ ] **Step 4: Run tests to verify they pass**
Run: `cd web && npx vitest run src/modules/jira/issues/`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add web/src/modules/jira/issues/
git commit -m "Вложения: без прежней ошибки после повтора, 401 — экран входа, порядок хаба (#124, #86 T6.2, T6.3, M-4)"
```
---
### Task 8: Доска, проекты, поиск UI-кита (#35 T4.1/M-15, #64 T9.1, M-6, #68 T3.3) и итоговая проверка
**Files:**
- Modify: `web/src/modules/jira/board/useBoardPrefs.ts` (`toggle`, `toggleHidden` :39-54)
- Modify: `web/src/modules/jira/board/model/fromHub.ts` (`hasSprint` :30)
- Modify: `web/src/projects/ProjectGate.tsx` (:11)
- Modify: `web/src/ui/inputs.tsx` (`SearchInput` :15-24)
- Test: `web/src/modules/jira/board/BoardPage.test.tsx` или новый `useBoardPrefs.test.tsx`, тест модели доски рядом с `fromHub.ts`, `web/src/projects/projects.test.tsx`, `web/src/ui/forms.test.tsx`
**Interfaces:**
- Consumes: `useProjects()` (`refetch`) из `web/src/api/queries.ts`.
- Produces: —
- [ ] **Step 1: Write the failing tests**
- `useBoardPrefs`: `renderHook`; в одном `act` два вызова `toggle(<флаг A>)` и `toggle(<флаг B>)` → оба флага включены
и в состоянии, и в `localStorage`; в одном `act` `toggleHidden("kind", "x")` и `toggleHidden("kind", "y")` → скрыты оба.
- `fromHub`: снимок, где спринт только у эпика (тип «Эпик», `neighbor: false`), у остальных своих задач спринта нет →
`hasSprint === false`.
- `projects.test.tsx`: `GET /projects` → 500, затем 200 → видно «Проекты не загрузились: …» и кнопка «Повторить»;
после нажатия — содержимое страницы проекта.
- `forms.test.tsx`: `SearchInput` со значением, щелчок «Очистить» → `onChange("")` и фокус в поле поиска
(`document.activeElement === getByRole("searchbox")`) — с обёрткой-состоянием, чтобы кнопка исчезла.
- [ ] **Step 2: Run tests to verify they fail**
Run: `cd web && npx vitest run src/modules/jira/board/ src/projects/ src/ui/`
Expected: FAIL.
- [ ] **Step 3: Implement**
- `useBoardPrefs`: `prefsRef` с последним значением; `toggle`/`toggleHidden` считают новое значение из `prefsRef.current`,
синхронно присваивают `prefsRef.current = next`, пишут `st.writeFlag`/`st.writeHidden` и `setPrefs(next)`.
- `fromHub`: `hasSprint = snap.issues.some((it) => !isEpic(it) && !it.neighbor && !!it.sprint)`.
- `ProjectGate`: `action={<Button onClick={retry}>Повторить</Button>}`, где `retry` перезапрашивает `GET /projects`.
- `SearchInput`: `ref` на `input`; «Очистить» — `onChange("")`, затем `ref.current?.focus()`.
- [ ] **Step 4: Run the whole suite**
Run (из корня репозитория): `make check`, затем `cd web && npm run e2e`
Expected: всё зелёное (pytest, lint, vitest, e2e).
- [ ] **Step 5: Commit**
```bash
git add web/src/
git commit -m "Доска, проекты, поиск: два переключения не теряются, спринт эпика, «Повторить», фокус после «Очистить» (#124, #35 T4.1, #64 T9.1, M-6, #68 T3.3)"
```