diff --git a/docs/superpowers/plans/2026-10-25-web-bugfixes.md b/docs/superpowers/plans/2026-10-25-web-bugfixes.md new file mode 100644 index 0000000..d119609 --- /dev/null +++ b/docs/superpowers/plans/2026-10-25-web-bugfixes.md @@ -0,0 +1,505 @@ +# Пакет веб-багов (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 `. +- Зелёным считается только весь набор: финально `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 | 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` ключ — `` со значком `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(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={}`, где `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)" +```