sdlc-hub/docs/superpowers/plans/2026-10-15-web-app-followups.md
2026-10-15 00:55:25 +04:00

1152 lines
66 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-app follow-ups (#29) 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:** закрыть все 46 пунктов jira-hub#29 (отложенные находки change `web-app`): видимое поведение доски, входа и страниц
задач, шрифты без интернета, уязвимость dev-зависимости, React-код и тесты.
**Architecture:** правки внутри существующего веб-приложения `web/` (React 19 + TS + Vite, TanStack Query, React Router) и
одной функции хаба `h_app`. Новых модулей два: `web/src/board/scrollMemory.ts` (прокрутка доски между страницами) и
`web/src/api/queryClient.test.ts`. API хаба, контракт с агентом, ключи `localStorage` и логика доски не меняются.
**Tech Stack:** Python 3.13 + pytest (хаб); TypeScript, React 19, TanStack Query 5, React Router 8, Vitest 5 + Testing
Library (jsdom), Playwright (e2e на тестовом хабе :8791), npm.
**Spec:** `openspec/changes/web-app-followups/` (proposal.md, design.md — решения 1–14 и таблица пунктов, specs/web-app,
specs/hub-api).
## Global Constraints
- Язык комментариев, сообщений коммитов и текстов интерфейса — русский; идентификаторы — английские.
- Коммит заканчивается строкой `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`; в теме коммита — `(#29)`.
- Новые npm-зависимости — точные версии: `npm install --before=2026-10-01 --save-exact <пакет>`; `package-lock.json` в git.
- `tsconfig`: `strict`, `erasableSyntaxOnly` — без parameter properties в классах (поля объявлять явно, как в `ApiError`).
- Ключи `localStorage` (`jira-hub-token`, `graph-<имя>…`) не меняются.
- Живой хаб :8790 и `~/.local/share/jira-hub` не трогать; e2e и ручные проверки — только тестовый хаб :8791
(`bash web/scripts/test-hub.sh`).
- Поведение доски на тех же данных совпадает с golden-эталонами `links.html@3931a88`; эталон не подгоняется под код.
- Каждый пункт #29 в отчёте задачи — с маркером и статусом `fixed <commit>` / `rejected <причина>`.
- Зелёным считается только весь набор: `make check` (pytest, tsc, eslint, vitest); для задач 4 и 7 — ещё `cd web && npm run e2e`.
- Перед изменением — `codegraph_explore` с `projectPath` = абсолютный путь worktree по своей области.
## Review Focus
1. Запрос ушёл со старым токеном, пользователь успел войти с новым, и только потом пришёл 401 — новый токен остаётся,
страница не сменяется экраном входа (Task 2, тест «запоздавший 401 от прежнего токена…»).
2. Пользователь ушёл с доски в карточку задачи, пока пакет «Обновить из Jira» выполняется, и вернулся — пакет снова
опрашивается, ровно одним опросом, кнопка недоступна (Task 3, тест «уход и возврат во время пакета»).
3. Прокрутка восстанавливается не позицией на момент ухода, сжатой браузером под короткую страницу карточки, а той, что
была на доске (Task 4: запоминание по событию scroll; e2e «M-3: прокрутка доски после возврата…»).
4. Адрес списка задач с фильтром, значение которого есть на доске, а снимок доски ещё грузится, — без мигания пометки
«(нет на доске)» (Task 5, тест «пока снимок доски грузится…»).
5. Сборка без интернета: в собранном `index.html` и CSS нет `http(s)://` ссылок (Task 7, шаг 2).
---
### Task 1: Хаб — нулевой байт в пути, типы файлов сборки, дубль импорта (T1.1, T1.4, T16.1)
Модель исполнителя: haiku (готовый код).
**Files:**
- Modify: `src/jira_hub/api.py` (функция `h_app`)
- Modify: `tests/test_web_app.py`
- Modify: `tests/test_api.py:161` (локальный `import urllib.request, urllib.error`)
**Interfaces:**
- Consumes: `api.Hub(db_path, backups_dir=None, release_dir=None, web_dir=None)`, `api.ApiError(status, error, detail)`.
- Produces: ничего нового для других задач.
- [ ] **Step 1: Падающие тесты** — в `tests/test_web_app.py`, в фикстуре `app_hub` после строки с `font.bin` добавить файлы:
```python
for name, data in (("logo.svg", b"<svg/>"), ("p.png", b"\x89PNG"), ("f.ico", b"\x00"), ("f.woff2", b"wOF2"),
("index-abc123.js.map", b"{}")):
(web / "assets" / name).write_bytes(data)
```
и в конец файла:
```python
@pytest.mark.parametrize("name,ctype", [("logo.svg", "image/svg+xml"), ("p.png", "image/png"), ("f.ico", "image/x-icon"),
("f.woff2", "font/woff2"), ("index-abc123.js.map", "application/json")])
def test_asset_content_types(app_hub, name, ctype):
status, r, _ = _get(app_hub, "/app/assets/" + name)
assert status == 200 and r.getheader("Content-Type") == ctype
@pytest.mark.parametrize("path", ["/app/assets/%00", "/app/assets/a%00.js", "/app/assets/%00/../index-abc123.js"])
def test_nul_in_asset_path_is_404(app_hub, path):
status, _, _ = _get(app_hub, path)
assert status == 404
```
- [ ] **Step 2: Запустить** `uv run pytest tests/test_web_app.py -q` — ожидается FAIL: `test_nul_in_asset_path_is_404`
получает 500 (`ValueError: embedded null byte` из `Path.resolve`). Тесты типов проходят сразу — это покрытие (T1.4).
- [ ] **Step 3: Реализация** — в `h_app` заменить блок `if rest.startswith("assets/"):` на:
```python
if rest.startswith("assets/"):
if "\x00" in rest: # T1.1: нулевой байт — 404, а не 500 из resolve()
raise ApiError(404, "not_found", "/app/" + rest.replace("\x00", "%00"))
assets = (hub.web_dir / "assets").resolve()
try:
f = (hub.web_dir / rest).resolve()
except (OSError, ValueError):
raise ApiError(404, "not_found", "/app/" + rest)
if not f.is_relative_to(assets) or not f.is_file():
raise ApiError(404, "not_found", "/app/" + rest)
return 200, Raw(ASSET_TYPES.get(f.suffix, "application/octet-stream"), f.read_bytes(), ASSET_CACHE)
```
- [ ] **Step 4: T16.1** — в `tests/test_api.py` удалить строку ` import urllib.request, urllib.error` внутри теста
(модули уже импортированы в начале файла).
- [ ] **Step 5: Проверка** `uv run pytest -q` — весь набор зелёный.
- [ ] **Step 6: Коммит**
```bash
git add src/jira_hub/api.py tests/test_web_app.py tests/test_api.py
git commit -m "fix(hub): нулевой байт в пути /app/assets — 404; тесты типов файлов сборки (T1.1, T1.4, T16.1, #29)"
```
---
### Task 2: Клиент и вход — токен в AuthError, 4xx на входе, тесты 401 (T3.2–T3.6)
Модель исполнителя: sonnet (интеграция: клиент, queryClient, useBoardSource, экран входа).
**Files:**
- Modify: `web/src/api/client.ts`
- Modify: `web/src/api/queryClient.ts`
- Modify: `web/src/board/useBoardSource.ts` (функция `report`)
- Modify: `web/src/auth/LoginScreen.tsx`
- Create: `web/src/api/queryClient.test.ts`
- Modify: `web/src/api/client.test.ts`, `web/src/auth/token.test.ts`, `web/src/auth/LoginScreen.test.tsx`
**Interfaces:**
- Consumes: `getToken()`, `clearToken(reason)`, `setToken()` из `auth/token.ts`.
- Produces:
- `class AuthError extends Error { readonly token: string; constructor(message: string, token: string) }` — токен,
с которым ушёл запрос;
- `function dropTokenOn(e: AuthError): void` (в `api/client.ts`) — очищает токен (`"bad"`, если токен был, иначе
`"none"`), только если `e.token === getToken()`. Все места, где по `AuthError` вызывался `clearToken`, вызывают
`dropTokenOn`.
- [ ] **Step 1: Найти все места** `grep -rn "instanceof AuthError" web/src` — ожидается `api/queryClient.ts`,
`board/useBoardSource.ts`, `auth/LoginScreen.tsx` и тесты. Других вызовов `clearToken` по `AuthError` быть не должно.
- [ ] **Step 2: Падающие тесты клиента** — в `web/src/api/client.test.ts` добавить:
```ts
it("AuthError хранит токен запроса; dropTokenOn не трогает новый токен (T3.5)", async () => {
mockFetch(() => json(401, { error: "unauthorized" }));
const e = await api.get("/board").catch((err: unknown) => err) as AuthError;
expect(e).toBeInstanceOf(AuthError);
expect(e.token).toBe("tok");
setToken("new-tok");
dropTokenOn(e);
expect(getAuth()).toEqual({ token: "new-tok", reason: "none" });
setToken("tok");
dropTokenOn(e);
expect(getAuth()).toEqual({ token: "", reason: "bad" });
});
```
(импорт: `import { clearToken, getAuth, setToken } from "../auth/token.ts";` и `dropTokenOn` из `./client.ts`).
- [ ] **Step 3: Тесты queryClient** — создать `web/src/api/queryClient.test.ts`:
```ts
import { beforeEach, describe, expect, it } from "vitest";
import { getAuth, setToken } from "../auth/token.ts";
import { AuthError, HubDownError } from "./client.ts";
import { makeQueryClient } from "./queryClient.ts";
const fail = (e: Error) => makeQueryClient().fetchQuery({ queryKey: ["x"], queryFn: () => Promise.reject(e) }).catch(() => undefined);
describe("queryClient", () => {
beforeEach(() => setToken("tok"));
it("401 с текущим токеном — экран входа с причиной «bad»", async () => {
await fail(new AuthError("токен не подошёл", "tok"));
expect(getAuth()).toEqual({ token: "", reason: "bad" });
});
it("401 с прежним токеном не стирает текущий (T3.5)", async () => {
await fail(new AuthError("токен не подошёл", "old"));
expect(getAuth()).toEqual({ token: "tok", reason: "none" });
});
it("прочие ошибки токен не трогают", async () => {
await fail(new HubDownError("хаб не отвечает"));
expect(getAuth().token).toBe("tok");
});
it("повторов нет: запрос с ошибкой выполняется один раз", async () => {
let n = 0;
await makeQueryClient().fetchQuery({ queryKey: ["y"], queryFn: () => { n += 1; return Promise.reject(new HubDownError("x")); } }).catch(() => undefined);
expect(n).toBe(1);
});
});
```
- [ ] **Step 4: T3.4** — в `web/src/auth/token.test.ts`, в тест «закрытое хранилище…», добавить перед `import`:
```ts
vi.spyOn(Storage.prototype, "removeItem").mockImplementation(() => { throw new DOMException("closed", "SecurityError"); });
```
и после `expect(t.getToken()).toBe("mem");`:
```ts
t.clearToken("bad");
expect(t.getAuth()).toEqual({ token: "", reason: "bad" });
```
- [ ] **Step 5: Экран входа** — в `web/src/auth/LoginScreen.test.tsx` заменить тавтологичный тест «после 401 в приложении
экран входа поясняет причину» (T3.3, T3.6) на сквозные и добавить тест 4xx (T3.2):
```tsx
it("401 в запросе страницы — экран входа «токен не подошёл», страница скрыта (T3.6)", async () => {
setToken("stale");
mockFetch(() => json(401, { error: "unauthorized" }));
function Page() { useBoard(); return <p>страница приложения</p>; }
renderWithProviders(<AuthGate><Page /></AuthGate>);
expect(await screen.findByText(/Токен не подошёл/)).toBeInTheDocument();
expect(screen.queryByText("страница приложения")).not.toBeInTheDocument();
expect(getAuth()).toEqual({ token: "", reason: "bad" });
});
it("запоздавший 401 от прежнего токена после входа с новым не выбрасывает на вход (T3.5)", async () => {
setToken("old-token");
let release!: (r: Response) => void;
const slow = new Promise<Response>((r) => { release = r; });
mockFetch((_m, url) => (url.pathname === "/board" ? slow : json(200, {})));
function Page() { useBoard(); return <p>страница приложения</p>; }
const { client } = renderWithProviders(<AuthGate><Page /></AuthGate>);
act(() => setToken("new-token"));
release(json(401, { error: "unauthorized" }));
await waitFor(() => expect(client.getQueryState(qk.board)?.status).toBe("error")); // 401 точно обработан
expect(screen.getByText("страница приложения")).toBeInTheDocument();
expect(getAuth()).toEqual({ token: "new-token", reason: "none" });
});
it("хаб отверг проверку токена (4xx) — текст хаба, не «хаб не отвечает» (T3.2)", async () => {
mockFetch(() => json(400, { error: "bad_request", detail: "заголовок Authorization без Bearer" }));
renderWithProviders(app);
await userEvent.type(screen.getByLabelText("Клиентский токен хаба"), "tok");
await userEvent.click(screen.getByRole("button", { name: "Войти" }));
expect(await screen.findByText(/заголовок Authorization без Bearer/)).toBeInTheDocument();
expect(screen.queryByText(/Хаб не отвечает/)).not.toBeInTheDocument();
});
```
Импорты: `act` — из `@testing-library/react`, `qk`, `useBoard` — из `../api/queries.ts`, `setToken` — из `./token.ts`.
- [ ] **Step 6: Запустить** `cd web && npx vitest run src/api src/auth` — ожидается FAIL: нет `dropTokenOn`, у `AuthError`
нет `token`, 4xx показывает «Хаб не отвечает», запоздавший 401 стирает новый токен.
- [ ] **Step 7: Реализация `client.ts`** — заменить `export class AuthError extends Error {}` на:
```ts
export class AuthError extends Error {
readonly token: string; // токен, с которым ушёл запрос: запоздавший 401 прежнему токену не выбрасывает на вход (T3.5)
constructor(message: string, token: string) {
super(message);
this.token = token;
}
}
// 401 выводит на экран входа, только если запрос ушёл с текущим токеном (R-3): новый токен уже проверен GET /settings
export function dropTokenOn(e: AuthError): void {
if (e.token !== getToken()) return;
clearToken(e.token ? "bad" : "none");
}
```
импорт: `import { clearToken, getToken, TOKEN_RE } from "../auth/token.ts";`. В `call()`:
`throw new AuthError("нет токена", token)`, `throw new AuthError("недопустимые символы в токене", token)`,
`if (resp.status === 401) throw new AuthError("токен не подошёл", token);`.
- [ ] **Step 8: `queryClient.ts`** — `const onError = (e: unknown) => { if (e instanceof AuthError) dropTokenOn(e); };`,
импорт `import { AuthError, dropTokenOn } from "./client.ts";`, импорт из `../auth/token.ts` удалить.
- [ ] **Step 9: `useBoardSource.ts`** — в `report`: `if (e instanceof AuthError) dropTokenOn(e);` вместо
`clearToken("bad")`; импорт `dropTokenOn` из `../api/client.ts`, импорт `clearToken` удалить, если больше не нужен.
- [ ] **Step 10: `LoginScreen.tsx`** — импорт `ApiError` из `../api/client.ts`; в `catch`:
```ts
setError(e instanceof AuthError ? BAD_TOKEN
: e instanceof ApiError ? `Хаб отверг проверку токена: ${e.detail ?? e.error}` // T3.2: 4xx — текст хаба
: HUB_DOWN);
```
- [ ] **Step 11: Проверка** `make check` — зелёный.
- [ ] **Step 12: Коммит**
```bash
git add web/src/api web/src/auth web/src/board/useBoardSource.ts
git commit -m "fix(web): запоздавший 401 прежнего токена не выбрасывает на вход, 4xx на входе — текст хаба (T3.2–T3.6, #29)"
```
---
### Task 3: Источник доски — строка пакета, снимок из файла, подхват только refresh (T10.1, T10.3–T10.6)
Модель исполнителя: sonnet (интеграция, тайминги опроса).
**Files:**
- Modify: `web/src/board/status.ts`, `web/src/board/status.test.ts`
- Modify: `web/src/board/useBoardSource.ts`
- Modify: `web/src/board/useBoardSource.test.tsx`
**Interfaces:**
- Consumes: `dropTokenOn` (Task 2), `api.get`, `qk.board`, `Batch.commands?: BatchCommand[]`.
- Produces: `BoardStatus` вариант `{ kind: "hub-down"; hasData: boolean; fromFile: boolean }` (поле `fromFile`
обязательное); `statusText` для `fromFile: true` — «хаб не отвечает — на доске снимок из файла».
- [ ] **Step 1: Тест текста** — в `status.test.ts` заменить строку с `hub-down` на:
```ts
expect(statusText({ kind: "hub-down", hasData: true, fromFile: false }).text).toBe("хаб не отвечает — показаны прежние данные");
expect(statusText({ kind: "hub-down", hasData: true, fromFile: true }).text).toBe("хаб не отвечает — на доске снимок из файла");
```
- [ ] **Step 2: Тесты источника** — в `useBoardSource.test.tsx`:
- хелпер `hook` принимает клиент:
```tsx
const hook = (client = makeQueryClient()) => ({ client, ...renderHook(() => useBoardSource(dicts, { pollMs: 10 }),
{ wrapper: ({ children }: { children: ReactNode }) => <QueryClientProvider client={client}>{children}</QueryClientProvider> }) });
```
(константу `wrapper` удалить; импорт `qk` из `../api/queries.ts`);
- в тесте «M-1: поздний ответ…» (T10.5) заменить `await waitFor(() => expect(h.count("GET /board")).toBe(1)); await sleep(20);`
на `const { result, client } = hook();` (вместо прежнего `const { result } = hook();`) и
```tsx
await waitFor(() => expect(client.getQueryData(qk.board)).toBeDefined()); // ответ хаба точно применён к кэшу
```
- в тесте «M-2» после `expect(h.count("GET /board")).toBe(2);` добавить
`expect(h.count("GET /batches")).toBe(1); // T10.1: после своего пакета список пакетов не перечитывается`;
- в тесте «хаб остановлен при «Перечитать»» ожидание — `{ kind: "hub-down", hasData: true, fromFile: false }`;
- новые тесты:
```tsx
it("T10.3: «Перечитать» и CSV во время пакета не стирают строку пакета", async () => {
hub({ "GET /batches/:id": () => batch("running") });
const { result } = hook();
await waitFor(() => expect(result.current.model.nodes.length).toBe(10));
await act(() => result.current.refreshFromJira());
await waitFor(() => expect(result.current.status).toMatchObject({ kind: "batch", id: 7 }));
act(() => result.current.reload());
expect(result.current.status).toMatchObject({ kind: "batch", id: 7 });
act(() => { result.current.loadFile("jira-export.csv", CSV); });
expect(result.current.status).toMatchObject({ kind: "batch", id: 7 });
});
it("T10.4: JSON-снимок без данных хаба — «на доске снимок из файла»", async () => {
hub({ "GET /board": () => { throw new TypeError("Failed to fetch"); } });
const { result } = hook();
await waitFor(() => expect(result.current.status).toMatchObject({ kind: "hub-down", fromFile: false }));
act(() => { result.current.loadFile("old-board.json", fx("old-board.json")); });
expect(result.current.source).toBe("file");
expect(result.current.status).toEqual({ kind: "hub-down", hasData: true, fromFile: true });
});
it("T10.4: пакет завершился при показанном CSV, хаб не ответил — «на доске снимок из файла»", async () => {
let polls = 0, down = false;
hub({
"GET /board": () => { if (down) throw new TypeError("Failed to fetch"); return json(200, SNAP); },
"GET /batches/:id": () => { if (++polls >= 3) down = true; return polls < 3 ? batch("running") : batch("done"); },
});
const { result } = hook();
await waitFor(() => expect(result.current.model.nodes.length).toBe(10));
await act(() => result.current.refreshFromJira());
act(() => { result.current.loadFile("jira-export.csv", CSV); });
await waitFor(() => expect(result.current.status).toEqual({ kind: "hub-down", hasData: true, fromFile: true }));
expect(result.current.source).toBe("file");
});
it("уход со страницы и возврат во время пакета — пакет подхвачен снова, один опрос (Review Focus 2)", async () => {
let listed: unknown[] = [];
const h = hub({ "GET /batches": () => json(200, { batches: listed }), "GET /batches/:id": () => batch("running") });
const first = hook();
await waitFor(() => expect(first.result.current.model.nodes.length).toBe(10));
await act(() => first.result.current.refreshFromJira());
listed = [{ id: 7, author: "viewer", status: "running" }];
first.unmount();
const second = hook(first.client);
await waitFor(() => expect(second.result.current.status).toMatchObject({ kind: "batch", id: 7 }));
expect(second.result.current.refreshing).toBe(true);
expect(h.maxInFlight("GET /batches/7")).toBe(1);
});
it("T10.6: незавершённый пакет viewer без refresh не подхватывается", async () => {
const h = hub({
"GET /batches": () => json(200, { batches: [{ id: 7, author: "viewer", status: "running" }] }),
"GET /batches/:id": () => batch("running", { type: "ensure_fields" }),
});
const { result } = hook();
await waitFor(() => expect(h.count("GET /batches/7")).toBe(1));
await sleep(50);
expect(result.current.refreshing).toBe(false);
expect(h.count("GET /batches/7")).toBe(1); // только проверка типа, опроса нет
});
```
- [ ] **Step 3: Запустить** `cd web && npx vitest run src/board/status.test.ts src/board/useBoardSource.test.tsx` —
ожидается FAIL: нет `fromFile`, строка пакета стирается, пакет `ensure_fields` подхватывается, лишний `GET /batches`.
- [ ] **Step 4: `status.ts`** — вариант `| { kind: "hub-down"; hasData: boolean; fromFile: boolean }` и
```ts
case "hub-down": return {
text: s.fromFile ? "хаб не отвечает — на доске снимок из файла"
: s.hasData ? "хаб не отвечает — показаны прежние данные" : "хаб не отвечает: запустите хаб (jira-hub serve) и нажмите «Перечитать»",
warn: true,
};
```
- [ ] **Step 5: `useBoardSource.ts`** — изменения (остальное без изменений):
```ts
// свежие значения для эффектов, которые не должны перезапускаться от их смены
const sourceRef = useRef(source);
useEffect(() => { sourceRef.current = source; }, [source]);
const activeRef = useRef(active);
useEffect(() => { activeRef.current = active; }, [active]);
const skipPickup = useRef(false); // T10.1: снимок, перечитанный после своего пакета, — пакет уже завершён
const report = useCallback((e: unknown, hasData: boolean, fromFile = false) => {
if (e instanceof AuthError) dropTokenOn(e);
else if (e instanceof HubDownError) setEvent({ kind: "hub-down", hasData, fromFile });
else setEvent({ kind: "error", message: e instanceof Error ? e.message : String(e) });
}, []);
useEffect(() => {
if (!board.error || board.error instanceof AuthError) return;
skipPickup.current = false;
report(board.error, !!board.data, sourceRef.current.kind === "file");
}, [board.error, board.errorUpdatedAt, board.data, report]);
// M-3 #20: после каждой успешной загрузки снимка — подхватить незавершённый пакет обновления от viewer
useEffect(() => {
if (!board.dataUpdatedAt) return;
if (skipPickup.current) { skipPickup.current = false; return; }
if (activeRef.current) return;
let gone = false;
(async () => {
const r = await api.get<{ batches: Batch[] }>("/batches", { limit: 20 });
const b = r.batches.find((x) => x.author === "viewer" && (x.status === "queued" || x.status === "running"));
if (!b || gone) return;
const full = await api.get<Batch>(`/batches/${b.id}`); // T10.6: в списке нет команд — тип смотрим в пакете (R-2)
if (gone || full.commands?.[0]?.type !== "refresh") return;
failures.current = 0;
setActive((cur) => cur ?? { id: b.id, agentOffline: false });
})().catch(() => { /* не критично: подхватим при следующей загрузке */ });
return () => { gone = true; };
}, [board.dataUpdatedAt]);
```
в `finish`: перед `void qc.invalidateQueries(...)` — `skipPickup.current = true;`;
в `reload`: `setEvent((cur) => (cur?.kind === "batch" ? cur : null));` вместо `setEvent(null)`;
в `loadFile`, ветка CSV: `setEvent((cur) => (cur?.kind === "batch" ? cur : null));` вместо `setEvent(null)`;
в `loadFile`, ветка «хаб не ответил — снимок из файла»:
`setEvent({ kind: "hub-down", hasData: r.model.nodes.length > 0, fromFile: true });`.
ESLint `react-hooks/exhaustive-deps` не должен ругаться: refs в зависимости не входят.
- [ ] **Step 6: Проверка** `make check`; затем `cd web && for i in $(seq 1 10); do npx vitest run src/board/useBoardSource.test.tsx || break; done` — 10 зелёных прогонов подряд.
- [ ] **Step 7: Коммит**
```bash
git add web/src/board/status.ts web/src/board/status.test.ts web/src/board/useBoardSource.ts web/src/board/useBoardSource.test.tsx
git commit -m "fix(web): строка пакета переживает «Перечитать», снимок из файла при недоступном хабе, подхват только refresh (T10.1, T10.3–T10.6, #29)"
```
---
### Task 4: Холст и страница доски — прокрутка, memo карточек, обработчики React (M-3, T11.1–T11.4, T12.1–T12.4)
Модель исполнителя: sonnet (интеграция с перетаскиванием и раскладкой; e2e обязателен).
**Files:**
- Create: `web/src/board/scrollMemory.ts`
- Modify: `web/src/board/components/BoardCanvas.tsx`, `Card.tsx`, `DatesBar.tsx`, `Dropdown.tsx`, `MultiSelect.tsx`
- Modify: `web/src/board/useBoardPrefs.ts`, `web/src/board/BoardPage.tsx`
- Modify: `web/src/board/components/BoardCanvas.test.tsx`, `web/src/board/BoardPage.test.tsx`
- Create: `web/src/board/components/Card.test.ts`, `web/src/board/components/Dropdown.test.tsx`, `web/src/board/components/DatesBar.test.tsx`
- Modify: `web/e2e/board.spec.ts`
**Interfaces:**
- Produces:
- `scrollMemory.ts`: `interface BoardScroll { x: number; y: number; left: number }`,
`readBoardScroll(): BoardScroll | null`, `saveBoardScroll(s: BoardScroll | null): void`;
- `Card.tsx`: `export function cardPropsEqual(a: CardProps, b: CardProps): boolean` — компаратор `memo`.
- [ ] **Step 1: `scrollMemory.ts`**
```ts
// M-3: прокрутка доски на время жизни вкладки (R-1) — переход в карточку задачи и обратно её не сбрасывает,
// перезагрузка страницы сбрасывает (значение живёт в памяти модуля)
export interface BoardScroll { x: number; y: number; left: number }
let saved: BoardScroll | null = null;
export const readBoardScroll = (): BoardScroll | null => saved;
export const saveBoardScroll = (s: BoardScroll | null): void => { saved = s; };
```
- [ ] **Step 2: Падающие тесты холста** — в `BoardCanvas.test.tsx`:
- T11.4, тест «сужение выборки…»: последнюю проверку заменить на
`await waitFor(() => expect(board().querySelectorAll(".card[data-key]")).toHaveLength(narrow.nodes.length));`
(тест — `async`, `waitFor` из `@testing-library/react`);
- T11.4, тест «полоса дат…»:
```tsx
const wrap = document.querySelector(".wrap") as HTMLElement;
expect((document.querySelector(".dates-in") as HTMLElement).style.width).toBe(`${wrap.offsetWidth}px`);
```
- T11.3 и M-3, новые тесты:
```tsx
it("T11.3: перетаскивание названия дорожки вниз — onMoveLane с новым местом", () => {
// в jsdom может не быть PointerEvent и захвата указателя — минимальные замены только для теста
if (typeof window.PointerEvent === "undefined") Object.defineProperty(window, "PointerEvent", { configurable: true, value: class extends MouseEvent {} });
if (typeof Element.prototype.setPointerCapture !== "function") Object.defineProperty(Element.prototype, "setPointerCapture", { configurable: true, value: () => {} });
const p = draw();
const name = [...board().querySelectorAll<HTMLElement>(".lane-name")].find((e) => e.textContent === "Гудвилл")!;
fireEvent.pointerDown(name, { pointerId: 1, clientY: 5 });
fireEvent.pointerMove(name, { pointerId: 1, clientY: 100000 });
fireEvent.pointerUp(name, { pointerId: 1, clientY: 100000 });
expect(p.onMoveLane).toHaveBeenCalledWith("Гудвилл", 3);
});
it("M-3: прокрутка запоминается по событию scroll и восстанавливается при новом монтировании", () => {
const to = vi.spyOn(window, "scrollTo").mockImplementation(() => {});
const first = renderWithProviders(<BoardCanvas {...baseProps()} />);
Object.defineProperty(window, "scrollY", { configurable: true, value: 640 });
fireEvent.scroll(window);
expect(readBoardScroll()).toMatchObject({ y: 640 });
first.unmount();
renderWithProviders(<BoardCanvas {...baseProps()} />);
expect(to).toHaveBeenCalledWith(0, 640);
Object.defineProperty(window, "scrollY", { configurable: true, value: 0 });
vi.restoreAllMocks();
});
```
в `web/src/test/setup.ts` в `afterEach` после `cleanup()` добавить `saveBoardScroll(null);` (импорт из
`../board/scrollMemory.ts`) — память прокрутки не переходит между тестами;
вынести создание пропсов из `draw` в `baseProps(over = {})` (тот же объект, что сейчас собирает `draw`), `draw`
вызывает `renderWithProviders(<BoardCanvas {...baseProps(over)} />)`; импорт `readBoardScroll, saveBoardScroll` из
`../scrollMemory.ts`. Если `onMoveLane` получает не 3, а индекс последней дорожки другой длины — проверять
`vis.lanes.length - 1` той же выборки.
- [ ] **Step 3: Тест компаратора** — создать `Card.test.ts`:
```ts
import { describe, expect, it } from "vitest";
import { cardPropsEqual, type CardProps } from "./Card.tsx";
const node = { key: "A-1" } as CardProps["node"];
const base: CardProps = { node, box: { x: 1, y: 2, w: 240, h: 120 }, rel: undefined, multiSprint: false, sprintSel: new Set() };
describe("cardPropsEqual (T11.1)", () => {
it("новый объект box с теми же числами — карточка не перерисовывается", () => {
expect(cardPropsEqual(base, { ...base, box: { x: 1, y: 2, w: 240, h: 120 } })).toBe(true);
});
it("сдвиг, другая карточка или другой выбор спринтов — перерисовка", () => {
expect(cardPropsEqual(base, { ...base, box: { x: 9, y: 2, w: 240, h: 120 } })).toBe(false);
expect(cardPropsEqual(base, { ...base, node: { key: "A-2" } as CardProps["node"] })).toBe(false);
expect(cardPropsEqual(base, { ...base, sprintSel: new Set(["S"]) })).toBe(false);
});
});
```
- [ ] **Step 4: Тесты Dropdown и DatesBar** — `Dropdown.test.tsx`:
```tsx
import { fireEvent, render, screen } from "@testing-library/react";
import { StrictMode } from "react";
import { describe, expect, it, vi } from "vitest";
import { Dropdown } from "./Dropdown.tsx";
describe("Dropdown (T12.1)", () => {
it("onOpen вызывается один раз на открытие, в том числе в StrictMode", () => {
const onOpen = vi.fn();
render(<StrictMode><Dropdown onOpen={onOpen} button={({ toggle }) => <button onClick={toggle}>открыть</button>}>{() => <p>меню</p>}</Dropdown></StrictMode>);
fireEvent.click(screen.getByText("открыть"));
expect(onOpen).toHaveBeenCalledTimes(1);
expect(screen.getByText("меню")).toBeInTheDocument();
fireEvent.click(screen.getByText("открыть"));
expect(onOpen).toHaveBeenCalledTimes(1);
});
});
```
`DatesBar.test.tsx`:
```tsx
import { fireEvent, render } from "@testing-library/react";
import { createRef } from "react";
import { describe, expect, it, vi } from "vitest";
import type { DateCol } from "../layout/layout.ts";
import { DatesBar } from "./DatesBar.tsx";
describe("DatesBar (T11.2)", () => {
it("отложенный кадр растяжки отменяется при размонтировании", () => {
const raf = vi.spyOn(window, "requestAnimationFrame").mockImplementation(() => 42);
const caf = vi.spyOn(window, "cancelAnimationFrame").mockImplementation(() => {});
const cols: DateCol[] = [{ date: "2026-10-20", x0: 0, w: 300, cls: "", count: 1 }];
const r = render(<DatesBar cols={cols} width={300} scrollLeft={0} wrapW={300} inRef={createRef()} onColWidth={vi.fn()} />);
const split = r.container.querySelector(".dsplit") as HTMLElement;
fireEvent.pointerDown(split, { clientX: 300 });
fireEvent.pointerMove(window, { clientX: 340 });
expect(raf).toHaveBeenCalled();
r.unmount();
expect(caf).toHaveBeenCalledWith(42);
});
});
```
- [ ] **Step 5: Тесты страницы** — в `BoardPage.test.tsx`:
```tsx
it("T12.3: --head-h снимается при уходе со страницы", async () => {
hub();
const r = page();
await screen.findByText(/^Хаб · данные на/);
r.unmount();
expect(document.documentElement.style.getPropertyValue("--head-h")).toBe("");
});
it("T12.2: переключатель пишет флаг в хранилище один раз, в том числе в StrictMode", async () => {
hub();
renderWithProviders(<StrictMode><BoardPage /></StrictMode>, { route: "/board", path: "/board" });
await screen.findByText(/^Хаб · данные на/);
const set = vi.spyOn(Storage.prototype, "setItem");
fireEvent.click(screen.getByRole("button", { name: /критический путь/ }));
expect(set.mock.calls.filter(([k]) => k === "graph-crit")).toEqual([["graph-crit", "1"]]);
});
it("T12.4: список исполнителей — группа флажков с подписью", async () => {
hub();
page();
await userEvent.click(await screen.findByRole("button", { name: /Все исполнители/ }));
expect(screen.getByRole("group", { name: "Все исполнители" })).toBeInTheDocument();
expect(screen.queryByRole("listbox")).toBeNull();
});
```
(`renderWithProviders` уже возвращает `unmount`; `page` вернуть его результат; `vi` — из `vitest`, `StrictMode` —
из `react`: React в StrictMode вызывает функцию-updater дважды, поэтому до исправления запись будет двойной.)
- [ ] **Step 6: Запустить** `cd web && npx vitest run src/board` — ожидается FAIL новых тестов.
- [ ] **Step 7: Реализация `Card.tsx`** — перед `export const Card`:
```ts
// T11.1: при перетаскивании одной карточки place() создаёт новые box у всех — сравниваем box по значению
export function cardPropsEqual(a: CardProps, b: CardProps): boolean {
const sameBox = a.box === b.box || (!!a.box && !!b.box && a.box.x === b.box.x && a.box.y === b.box.y && a.box.w === b.box.w && a.box.h === b.box.h);
return sameBox && a.node === b.node && a.rel === b.rel && a.multiSprint === b.multiSprint && a.sprintSel === b.sprintSel &&
a.onPointerDown === b.onPointerDown && a.onAutoSize === b.onAutoSize;
}
```
и второй аргумент `memo(function Card(...) {...}, cardPropsEqual)`.
- [ ] **Step 8: `BoardCanvas.tsx`, прокрутка** — импорт `readBoardScroll, saveBoardScroll` из `../scrollMemory.ts`,
`useEffect` из `react`;
после `useLayoutEffect` с `syncScroll`:
```ts
// M-3: прокрутка доски переживает переход в карточку задачи и обратно (R-1); восстанавливаем, когда карточки расставлены
const restore = useRef(readBoardScroll());
useLayoutEffect(() => {
const s = restore.current;
if (!s || !placed) return;
restore.current = null;
window.scrollTo(s.x, s.y);
if (wrapRef.current) wrapRef.current.scrollLeft = s.left;
syncScroll();
}, [placed, syncScroll]);
// запоминаем по событиям прокрутки, а не при размонтировании: к моменту ухода браузер может уже сжать прокрутку
// под короткую страницу карточки
useEffect(() => {
const w = wrapRef.current;
const save = () => saveBoardScroll({ x: window.scrollX, y: window.scrollY, left: w?.scrollLeft ?? 0 });
window.addEventListener("scroll", save, { passive: true });
w?.addEventListener("scroll", save, { passive: true });
return () => { window.removeEventListener("scroll", save); w?.removeEventListener("scroll", save); };
}, []);
```
- [ ] **Step 9: `DatesBar.tsx`** — импорт `useEffect`; после `const raf = useRef(0);`:
```ts
useEffect(() => () => { if (raf.current) cancelAnimationFrame(raf.current); }, []); // T11.2
```
- [ ] **Step 10: `Dropdown.tsx`** — `const toggle = () => { if (!open) onOpen?.(); setOpen(!open); };` // T12.1: побочное действие вне updater
- [ ] **Step 11: `useBoardPrefs.ts`** — `toggle` и `toggleHidden` без побочных действий в updater (T12.2):
```ts
const toggle = useCallback((name: st.FlagName | "snap" | "table" | "problems") => {
const isView = name === "snap" || name === "table" || name === "problems";
const on = isView ? !prefs[name] : !prefs.filters[name];
st.writeFlag(name, on);
setPrefs((p) => (isView ? { ...p, [name]: on } : { ...p, filters: { ...p.filters, [name]: on } }));
}, [prefs]);
const toggleHidden = useCallback((what: "kind" | "edge", value: string) => {
const key = what === "kind" ? "hideKind" : "hideEdge";
const next = new Set(prefs.filters[key]);
if (next.has(value)) next.delete(value); else next.add(value);
const filters = { ...prefs.filters, [key]: next };
st.writeHidden(filters.hideKind, filters.hideEdge);
setPrefs((p) => ({ ...p, filters: { ...p.filters, [key]: next } }));
}, [prefs]);
```
- [ ] **Step 12: `BoardPage.tsx`** — `useRef`/`useLayoutEffect` шапки заменить на ref-колбэк с очисткой (T12.3; React 19
поддерживает возврат функции очистки из ref-колбэка); импорт `useCallback`, убрать неиспользуемые:
```ts
// высота закреплённой шапки — ящик «Проблемы» и полоса дат липнут под ней (строки 1016–1019); при уходе — снимаем
const headRef = useCallback((el: HTMLDivElement | null) => {
if (!el) return;
const root = document.documentElement.style;
const set = () => root.setProperty("--head-h", (getComputedStyle(el).position === "sticky" ? el.offsetHeight : 0) + "px");
set();
const ro = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(set);
ro?.observe(el);
return () => { ro?.disconnect(); root.removeProperty("--head-h"); };
}, []);
```
- [ ] **Step 13: `MultiSelect.tsx`** — кнопка `aria-haspopup="dialog"`; список
`<div className="dd-list" role="group" aria-label={allLabel}>` (T12.4: флажки — это группа, не listbox без option).
- [ ] **Step 14: e2e прокрутки** — в `web/e2e/board.spec.ts`:
```ts
test("M-3: прокрутка доски после возврата из карточки задачи", async ({ page }) => {
await page.setViewportSize({ width: 1600, height: 600 });
await login(page);
await expect(page.locator(".board .card[data-key]").first()).toBeVisible();
const y = await page.evaluate(() => { window.scrollTo(0, document.documentElement.scrollHeight); return window.scrollY; });
expect(y).toBeGreaterThan(100);
// щелчок без прокрутки к элементу: иначе Playwright сам сдвинет страницу перед переходом
await page.evaluate(() => (document.querySelector(".board .card[data-key] a.open-card") as HTMLElement).click());
await expect(page).toHaveURL(/\/app\/issues\//);
await page.getByRole("link", { name: "Доска", exact: true }).click();
await expect.poll(() => page.evaluate(() => window.scrollY)).toBeGreaterThan(y - 2);
});
```
- [ ] **Step 15: Проверка** `make check` и `cd web && npm run e2e` — зелёные.
- [ ] **Step 16: Коммит**
```bash
git add web/src/board web/e2e/board.spec.ts
git commit -m "feat(web): прокрутка доски после возврата, memo карточек, обработчики без побочных действий в updater (M-3, T11.1–T11.4, T12.1–T12.4, #29)"
```
---
### Task 5: Задачи и карточка — фильтр вне вариантов, ссылки, команды, заметка (T13.1, O-10, O-11, T14.2, T14.3)
Модель исполнителя: haiku (готовый код).
**Files:**
- Modify: `web/src/issues/options.ts`, `web/src/issues/IssuesPage.tsx`, `web/src/issues/IssuePage.tsx`,
`web/src/issues/NoteForm.tsx`, `web/src/issues/issues.css`
- Modify: `web/src/issues/IssuesPage.test.tsx`, `web/src/issues/IssuePage.test.tsx`
**Interfaces:**
- Produces: `withCurrent(opts: Opt[], value: string | undefined, loaded: boolean): Opt[]` в `options.ts`;
`type Opt = { value: string; label: string }` экспортируется.
- [ ] **Step 1: Падающие тесты** — в `IssuesPage.test.tsx` (хелперы файла — `mockFetch`, `json`, снимок доски из
`../board/__fixtures__/hub-basic.board.json`, как в соседних тестах):
```tsx
it("T13.1: значение фильтра из адреса вне вариантов — в списке с пометкой", async () => {
hub(() => [row(1)]);
renderWithProviders(<IssuesPage />, { route: "/issues?sprint=AWPCA_Спринт 0", path: "/issues" });
const sel = await screen.findByLabelText("Спринт");
await waitFor(() => expect(within(sel).getByRole("option", { name: "AWPCA_Спринт 0 (нет на доске)" })).toBeInTheDocument());
expect(sel).toHaveValue("AWPCA_Спринт 0");
});
it("T13.1: пока снимок доски грузится, пометки «(нет на доске)» нет", async () => {
mockFetch((_m, url) => {
if (url.pathname === "/settings") return json(200, settings);
if (url.pathname === "/board") return new Promise<Response>(() => {}); // снимок не приходит до конца теста
return json(200, { issues: [row(1)] });
});
renderWithProviders(<IssuesPage />, { route: "/issues?sprint=AWPCA_Спринт 2", path: "/issues" });
await screen.findByRole("link", { name: "AURORA-9001" });
const sel = await screen.findByLabelText("Спринт");
expect(within(sel).queryByRole("option", { name: /нет на доске/ })).toBeNull();
expect(within(sel).getByRole("option", { name: "AWPCA_Спринт 2" })).toBeInTheDocument();
});
```
(импорт `within` из `@testing-library/react`.)
В `IssuePage.test.tsx`:
```tsx
it("O-11: задача без команд — текст вместо пустой таблицы", async () => {
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { commands: [] })));
open("/issues/AURORA-9001");
expect(await screen.findByText("команд по задаче не было")).toBeInTheDocument();
expect(screen.queryByRole("columnheader", { name: "Команда" })).toBeNull();
});
it("T14.3: id подсказок «статуса по факту» уникален и связан с полем", async () => {
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001")));
open("/issues/AURORA-9001");
const input = await screen.findByLabelText("Статус по факту");
const id = input.getAttribute("list")!;
expect(id).not.toBe("fact-statuses");
expect(document.getElementById(id)?.tagName).toBe("DATALIST");
});
```
T14.2 — в тестах «поздний ответ прежней карточки…» и «ответ «Сохранить» после ухода…» заменить
`await new Promise((r) => setTimeout(r, 20));`:
- в первом — на `await waitFor(() => expect(client.getQueryData(qk.issue("AURORA-9001"))).toBeDefined());`
(клиент создать в тесте `const client = makeQueryClient();` и передать `open("/issues/AURORA-9001", client)`);
- во втором — на `await waitFor(() => expect(client.isMutating()).toBe(0));`.
Пауза 450 мс в `IssuesPage.test.tsx` («T13.2: внешняя смена адреса…») не трогается: это проверка, что за время задержки
поиска (300 мс) адрес не перезаписан, — ей нужно реальное время.
- [ ] **Step 2: Запустить** `cd web && npx vitest run src/issues` — FAIL новых тестов.
- [ ] **Step 3: `options.ts`** — `export type Opt = …` и
```ts
// T13.1: значение фильтра из адреса, которого нет среди вариантов (закрытый спринт, ушедший исполнитель), видно в списке;
// пока снимок доски не загружен, пометку не ставим (R-4)
export const withCurrent = (opts: Opt[], value: string | undefined, loaded: boolean): Opt[] =>
!value || opts.some((o) => o.value === value) ? opts
: [{ value, label: loaded ? `${value} (нет на доске)` : value }, ...opts];
```
- [ ] **Step 4: `IssuesPage.tsx`** — в `SELECTS.map`:
`{withCurrent(opts[name], filters[name], !!board.data).map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}`.
- [ ] **Step 5: `IssuePage.tsx`** — таблицу команд обернуть:
```tsx
{i.commands.length ? (
<table className="grid">…как сейчас…</table>
) : <p className="hint">команд по задаче не было</p>}
```
`issues.css` — добавить `.issue a{color:var(--accent)}` (O-10).
- [ ] **Step 6: `NoteForm.tsx`** — `import { useEffect, useId, useMemo, useState } from "react";`,
`const listId = useId();`, `<input list={listId} …/>` и `<datalist id={listId}>` (T14.3).
- [ ] **Step 7: Проверка** `make check` — зелёный.
- [ ] **Step 8: Коммит**
```bash
git add web/src/issues
git commit -m "fix(web): фильтр из адреса вне вариантов, ссылки цветом темы, «команд по задаче не было», тесты без пауз (T13.1, O-10, O-11, T14.2, T14.3, #29)"
```
---
### Task 6: Модель, хранилище, эталоны (T2.1, T5.1, T6.3, T7.1, T9.1, T9.3, T9.4)
Модель исполнителя: sonnet (перегенерация эталонов и разбор расхождений).
**Files:**
- Modify: `web/src/test/canon.ts`, `web/src/test/canon.test.ts`
- Modify: `web/src/board/analysis/facets.ts` (`initialWho`)
- Modify: `web/src/board/storage.ts` (`colWidths`), `web/src/board/storage.test.ts`
- Modify: `web/src/board/model/fromJson.ts` (порядок импортов)
- Modify: `web/src/board/model/util.test.ts`
- Modify: `web/src/board/__fixtures__/scenarios.json`; перегенерировать `web/src/board/__fixtures__/*.expected.json`
**Interfaces:** без новых экспортов.
- [ ] **Step 1: T2.1** — тест в `canon.test.ts`:
```ts
it("порядок Set не зависит от локали: сравнение по кодам символов", () => {
expect(canon(new Set(["б", "Б", "b", "B"]))).toEqual(["B", "b", "Б", "б"]);
});
```
реализация в `canon.ts`: сортировка `.sort((a, b) => { const x = JSON.stringify(a), y = JSON.stringify(b); return x < y ? -1 : x > y ? 1 : 0; })`.
- [ ] **Step 2: T6.3** — в `util.test.ts`:
```ts
it("notStarted, unassigned, whoKey, fmtStamp, emptyModel", () => {
expect(notStarted("Backlog")).toBe(true);
expect(notStarted("К выполнению")).toBe(true);
expect(notStarted("В работе")).toBe(false);
expect(unassigned("")).toBe(true);
expect(unassigned("Не назначен")).toBe(true);
expect(unassigned("Иванов")).toBe(false);
expect(whoKey(" Иванов ")).toBe("Иванов");
expect(whoKey("unassigned")).toBe("");
expect(fmtStamp("не дата")).toBe("не дата");
expect(fmtStamp("2026-10-14T09:05:00")).toMatch(/^14\.10 09:05$/);
expect(emptyModel("пусто")).toMatchObject({ nodes: [], links: [], emptyText: "пусто", missing: 0 });
});
```
- [ ] **Step 3: T9.1 и T9.3** — в `storage.test.ts` (новый `describe("операции хранилища")`, `beforeEach(() => localStorage.clear())`):
```ts
it("colWidths пропускает только конечные числа (T9.1)", () => {
localStorage.setItem("graph-colw:board", JSON.stringify({ "2026-10-20": 400, "2026-10-21": "x", "2026-10-22": null }));
expect(colWidths()).toEqual({ "2026-10-20": 400 });
});
it("toggleLane сворачивает и разворачивает", () => {
toggleLane("Гудвилл");
expect(foldedSet()).toEqual(new Set(["Гудвилл"]));
toggleLane("Гудвилл");
expect(foldedSet()).toEqual(new Set());
});
it("moveLane переставляет и сохраняет имена вне текущей выборки", () => {
localStorage.setItem("graph-lanes:board", JSON.stringify(["A", "B", "C", "Скрытая"]));
expect(moveLane(["A", "B", "C"], "A", 2)).toBe(true);
expect(laneOrder()).toEqual(["B", "C", "A", "Скрытая"]);
expect(moveLane(["A", "B", "C"], "A", 5)).toBe(false);
expect(moveLane(["A", "B", "C"], "нет", 0)).toBe(false);
});
it("resetLayout: обычный режим не трогает «по датам», режим дат чистит и ширины колонок", () => {
for (const k of ["pos", "posdate", "lanes", "fold", "colw"]) localStorage.setItem(`graph-${k}:board`, "{}");
resetLayout(false);
expect(Object.keys(localStorage).sort()).toEqual(["graph-colw:board", "graph-posdate:board"]);
resetLayout(true);
expect(Object.keys(localStorage)).toEqual([]);
});
it("exportFileName: имя файла без .csv и недопустимых символов, дата из now", () => {
expect(exportFileName({ fileName: "Jira (1).csv" } as BoardModel, "2026-10-14T12:00:00Z")).toBe("links-Jira_1_-2026-10-14.json");
expect(exportFileName({ fileName: "" } as BoardModel, "2026-10-14T12:00:00Z")).toBe("links-board-2026-10-14.json");
});
it("applyJsonSettings: mode=all — пустой выбор спринтов", () => {
applyJsonSettings({ layout: {}, layoutByDate: {}, laneOrder: [], collapsedLanes: null, dateColumns: null,
filters: { mode: "all", sprints: ["S 2"] } } as unknown as JsonImport);
expect(localStorage.getItem("graph-sprint:board")).toBe("[]");
});
it("закрытое хранилище: чтение — значения по умолчанию, запись и удаление без исключений", () => {
const boom = () => { throw new DOMException("closed", "SecurityError"); };
vi.spyOn(Storage.prototype, "getItem").mockImplementation(boom);
vi.spyOn(Storage.prototype, "setItem").mockImplementation(boom);
vi.spyOn(Storage.prototype, "removeItem").mockImplementation(boom);
expect(laneOrder()).toEqual([]);
expect(() => { toggleLane("A"); resetLayout(true); setColWidth("2026-10-20", 400); }).not.toThrow();
});
```
Если ожидаемое имя в `exportFileName` расходится с фактическим, свериться с `links.html@3931a88`
(`git show 3931a88:src/jira_hub/web/links.html | grep -n "links-"`): мерило — старая страница, тест правится по ней.
Если `JsonImport` требует других полей, взять форму из `types.ts`.
- [ ] **Step 4: Запустить** `cd web && npx vitest run src/test src/board/storage.test.ts src/board/model` — FAIL:
`colWidths` пропускает строки и `null`, порядок `canon` локальный.
- [ ] **Step 5: Реализация**
- `storage.ts`:
```ts
export function colWidths(): Record<string, number> {
const v = store.json<unknown>(fileKey("colw"), {});
if (!isObj(v)) return {};
return Object.fromEntries(Object.entries(v).filter(([, w]) => num(w))) as Record<string, number>; // T9.1
}
```
- `facets.ts`, `initialWho` (T7.1, `whoItems` один раз; условие — то же, что у `whoBox`):
```ts
export function initialWho(model: BoardModel, f: Filters, remembered: unknown): Set<string> {
if (!model.nodes.length) return new Set();
const { total } = whoItems(model, f);
const names = Object.keys(total);
if (!names.length || (names.length === 1 && names[0] === "")) return new Set();
return new Set(strList(remembered).filter((w) => total[w] !== undefined));
}
```
- `fromJson.ts` (T9.4): порядок импортов — внешние/`../constants.ts`, `../storage.ts`, `../types.ts`, затем `./links.ts`,
`./util.ts` (по пути, по алфавиту).
- [ ] **Step 6: T5.1** — в `scenarios.json` добавить последним элементом:
```json
{"name": "saved-short", "filters": {}, "saved": {"AURORA-9001": {"x": 30, "ry": 50, "w": 240, "h": 120}, "AURORA-9002": {"x": 320, "ry": 50, "w": 240, "h": 136}, "AURORA-9004": {"x": 30, "ry": 50, "w": 240, "h": 200}, "AURORA-9008": {"x": 30, "ry": 50, "w": 280, "h": 320}}}
```
- [ ] **Step 7: Перегенерировать эталоны** `cd web && npm run golden`, затем `git diff --stat src/board/__fixtures__/`.
Ожидается: в `hub-basic.expected.json` новый сценарий `saved-short` с числовыми `clamp` у четырёх карточек; прочие
изменения — только порядок элементов из `Set` после смены сортировки `canon` (T2.1). Любое другое изменение эталона —
стоп и разбор: это расхождение генератора, не кода.
- [ ] **Step 8: Проверка** `make check` — зелёный; `layout.test.ts` проходит сценарий `saved-short`
(`grep -c '"[0-9]"' src/board/__fixtures__/hub-basic.expected.json` больше прежнего).
- [ ] **Step 9: Коммит**
```bash
git add web/src/test web/src/board
git commit -m "test(web): сценарий saved-short для обрезки заголовков, canon без локали, тесты хранилища и util (T2.1, T5.1, T6.3, T7.1, T9.1, T9.3, T9.4, #29)"
```
---
### Task 7: Окружение и e2e — шрифты, source-map-js, порт, тесты оболочки (T2.2, O-1, T4.1–T4.5, T15.1, T15.2, T15.4, T15.6)
Модель исполнителя: sonnet (зависимости, сборка, e2e).
**Files:**
- Modify: `web/package.json`, `web/package-lock.json`, `web/index.html`, `web/src/main.tsx`
- Modify: `web/src/App.tsx`, `web/src/App.test.tsx`, `web/src/shell/theme.test.ts`
- Modify: `web/playwright.config.ts`, `web/e2e/hub.ts`, `web/e2e/geometry.spec.ts`, `web/e2e/board.spec.ts`
- Modify: `web/scripts/test-hub.sh`
**Interfaces:**
- Produces: `export const HUB_PORT = 8791; export const HUB_URL = \`http://127.0.0.1:${HUB_PORT}\`;` в `playwright.config.ts`
(рядом с `HUB_HOME`); `e2e/hub.ts` берёт `HUB_URL` оттуда.
- [ ] **Step 1: Шрифты (T2.2)** — `cd web && npm install --before=2026-10-01 --save-exact @fontsource/ibm-plex-sans @fontsource/ibm-plex-mono`;
в `main.tsx` до импорта `App`:
```ts
// шрифты входят в сборку: приложение не ходит в интернет (T2.2)
import "@fontsource/ibm-plex-sans/400.css";
import "@fontsource/ibm-plex-sans/500.css";
import "@fontsource/ibm-plex-sans/600.css";
import "@fontsource/ibm-plex-mono/500.css";
```
из `index.html` удалить строку `<link rel="stylesheet" href="https://fonts.googleapis.com/…">`.
- [ ] **Step 2: Проверка сборки без внешних адресов** — `npm run build && grep -rEn "https?://" ../src/jira_hub/web/app/index.html ../src/jira_hub/web/app/assets/*.css`
— ожидается пусто; `ls ../src/jira_hub/web/app/assets/*.woff2` — файлы есть.
- [ ] **Step 3: source-map-js (O-1)** — в `package.json` добавить `"overrides": { "source-map-js": "1.2.2" }`,
`npm install --before=2026-10-01`, `npm audit` — ожидается 0 high. Если high остаётся — откатить `overrides`, в отчёте
O-1 `rejected` с выводом `npm audit`.
- [ ] **Step 4: Тесты оболочки** — `App.test.tsx`:
- T4.4: один импорт `import { render, screen } from "@testing-library/react";`;
- T4.1: тест «переход «Задачи» и обратно» дополнить:
```tsx
await userEvent.click(screen.getByRole("link", { name: "Доска" }));
expect(router.state.location.pathname).toBe("/app/board");
expect(await screen.findByTestId("page-board")).toBeInTheDocument();
```
- T4.2: тест «неизвестный путь…» дополнить:
```tsx
expect(screen.getByRole("link", { name: "доска" })).toHaveAttribute("href", "/app/board");
expect(screen.getByRole("link", { name: "задачи" })).toHaveAttribute("href", "/app/issues");
```
- T4.3: в тесте «исключение в странице…» `<App.Layout />` заменить на `<Layout />` (`import { Layout } from "./shell/Layout.tsx";`);
в `App.tsx` удалить `App.Layout = Layout;` и импорт `Layout`.
- T4.5: `theme.test.ts` — `afterEach(() => document.documentElement.removeAttribute("data-theme"));` (импорт `afterEach`).
- [ ] **Step 5: Порт одной константой (T15.4)** — `playwright.config.ts`:
```ts
export const HUB_PORT = 8791;
export const HUB_URL = `http://127.0.0.1:${HUB_PORT}`;
…
use: { baseURL: HUB_URL, … },
webServer: { …, url: `${HUB_URL}/app/`, env: { JIRA_HUB_HOME: HUB_HOME, PORT: String(HUB_PORT) } },
```
`e2e/hub.ts`: `import { HUB_HOME, HUB_URL } from "../playwright.config.ts";`, `const BASE = HUB_URL;`.
- [ ] **Step 6: Занятый порт (T15.6)** — в `scripts/test-hub.sh` перед `rm -rf`:
```bash
if lsof -nP -iTCP:"$PORT" -sTCP:LISTEN >/dev/null 2>&1; then
echo "отказ: порт $PORT занят — остановите прежний тестовый хаб (lsof -iTCP:$PORT -sTCP:LISTEN)" >&2; exit 3
fi
```
Проверка: запустить `bash scripts/test-hub.sh` в фоне, второй запуск — выход 3 с этим текстом; остановить первый.
- [ ] **Step 7: geometry без тавтологии (T15.1)** — в `geometry.spec.ts`, тест «высота карточек…», вместо сравнения с
формулой проверять, что содержимое помещается и высоты на сетке:
```ts
const rows = await page.evaluate(() => [...document.querySelectorAll<HTMLElement>(".board .card[data-key]")].map((e) => ({
key: e.dataset.key!, h: e.offsetHeight, fits: e.scrollHeight <= e.clientHeight + 1,
})));
expect(rows.length).toBeGreaterThan(0);
for (const r of rows) {
expect(r.fits, `${r.key}: содержимое не помещается`).toBe(true);
expect(r.h % 8, `${r.key}: высота не на сетке 8`).toBe(0);
}
expect(new Set(rows.map((r) => r.h)).size, "все карточки одной высоты — высота не по содержимому").toBeGreaterThan(1);
```
- [ ] **Step 8: растяжка (T15.2)** — в `board.spec.ts`, тест «растяжка за уголок…»: `import { CW } from "../src/board/constants.ts";`;
`await expect.poll(async () => (await card.boundingBox())!.width).toBeGreaterThan(w0 + 60);` и
`await expect.poll(async () => Math.round((await card.boundingBox())!.width)).toBe(CW);`.
- [ ] **Step 9: Проверка** `make check`; `cd web && for i in $(seq 1 10); do npm run e2e || break; done` — 10 зелёных
прогонов подряд (Review Focus 5 — шаг 2).
- [ ] **Step 10: Коммит**
```bash
git add web/package.json web/package-lock.json web/index.html web/src web/playwright.config.ts web/e2e web/scripts/test-hub.sh
git commit -m "build(web): шрифты в сборке, source-map-js 1.2.2, порт e2e одной константой, тесты оболочки и геометрии (T2.2, O-1, T4.1–T4.5, T15.1, T15.2, T15.4, T15.6, #29)"
```
---
### Task 8: Документация и ручная проверка (закрытие #29)
Модель исполнителя: haiku (документы и сверка).
**Files:**
- Modify: `CLAUDE.md` (строка «Спеки: … текущего change нет …»)
- Modify: `README.md` (раздел веб-приложения — шрифты в сборке, без интернета)
- Modify: `openspec/changes/web-app-followups/design.md` (раздел «Decisions made without discussion» — рулинги исполнения)
- [ ] **Step 1: `CLAUDE.md`** — заменить «текущего change нет (последний — `openspec/changes/archive/2026-10-15-web-app/`)»
на «текущий change — `openspec/changes/web-app-followups/`».
- [ ] **Step 2: `README.md`** — в разделе о веб-приложении одна строка: «Шрифты (IBM Plex) входят в сборку: приложение
работает без доступа в интернет».
- [ ] **Step 3: Ручной проход на тестовом хабе** — `bash web/scripts/test-hub.sh` (фон), `cd web && npm run build`,
открыть `http://127.0.0.1:8791/app/board` в Playwright (токен — `web/.e2e-hub/tokens.json`, поле `client`):
прокрутка после «ⓘ» и «Доска»; «Перечитать» во время «Обновить из Jira» (учебный агент — `e2e/hub.ts`
`agentServeRefresh`) не стирает строку пакета; `/app/issues?sprint=AWPCA_Спринт 0` — пометка «(нет на доске)»;
карточка задачи без команд — «команд по задаче не было», ссылки цветом темы в светлой и тёмной теме;
во вкладке Network нет запросов вне `127.0.0.1:8791`. Тестовый хаб остановить.
- [ ] **Step 4: Проверка** `make check`, `openspec validate web-app-followups --strict`.
- [ ] **Step 5: Коммит**
```bash
git add CLAUDE.md README.md openspec/changes/web-app-followups/design.md
git commit -m "docs: web-app-followups — CLAUDE.md, README, рулинги исполнения (#29)"
```