diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 00f1b26..0150036 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -24,8 +24,9 @@ | `importer` | разовый импорт CSV-пакета в заметки, сверка с зеркалом | `mirror`, `settings` | | `cli` | `init`, `serve`, `import`, `mcp`, `tokens`, `agent publish` | `api`, `mcp_server`, `importer`, `release`, `settings`, `db`, `paths` | -`web/` — веб-приложение хаба (React + TypeScript + Vite): вход по клиентскому токену, страницы «Доска» (`/app/board`) -и «Задачи» (`/app/issues`); текущий проект — `?project=<метка>` в адресе (`web/src/projects/`: контекст проекта, выпадающий список «Проект», окно «Новый проект», заглушка «проект не найден»; страницы пересоздаются по `key={project}`); карточка `/app/issues/{key}` — окно поверх страницы, с которой её открыли: `shell/Layout.tsx` рисует +`web/` — веб-приложение хаба (React + TypeScript + Vite): вход по клиентскому токену, страницы «Доска» (`/app/board`), +«Задачи» (`/app/issues`) и «Лента» (`/app/feed`: журнал изменений `GET /changes` за период; `web/src/feed/` — чистые +функции `events.ts` (период, виды, текст события, группировка) и `EventList.tsx`, общий с блоком «История» карточки); текущий проект — `?project=<метка>` в адресе (`web/src/projects/`: контекст проекта, выпадающий список «Проект», окно «Новый проект», заглушка «проект не найден»; страницы пересоздаются по `key={project}`); карточка `/app/issues/{key}` — окно поверх страницы, с которой её открыли: `shell/Layout.tsx` рисует страницу по адресу из `location.state.background` (`useRoutes` с явным адресом) и `issues/IssueModal.tsx` поверх неё. Собирается `make web` в `src/jira_hub/web/app/`; `api` отдаёт сборку без токена (`/app/assets/*` — файлы, прочие `/app…` — `index.html`, 503 без сборки), `/` и `/links` перенаправляет на `/app/board`. Данные — только через HTTP API с клиентским токеном; `mirror.board` отдаёт зеркало без преобразований, diff --git a/CLAUDE.md b/CLAUDE.md index 00ebcc6..e5d691f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,7 +24,7 @@ - `uv sync` — установка окружения. - `uv run jira-hub init --config PATH [--force]` — настройки проекта из `config.example.json`, создание токенов. - `uv run jira-hub serve [--host] [--port]` — локальный запуск хаба. -- Веб-приложение — `http://127.0.0.1:8790/app/` (доска — `/app/board?project=<метка>`, задачи — `/app/issues?project=<метка>`; вход по клиентскому токену); `/` и `/links` перенаправляют на доску. +- Веб-приложение — `http://127.0.0.1:8790/app/` (доска — `/app/board?project=<метка>`, задачи — `/app/issues?project=<метка>`, лента изменений — `/app/feed?project=<метка>`; вход по клиентскому токену); `/` и `/links` перенаправляют на доску. - `make web` — сборка приложения (после `git pull` на Mac mini, до перезапуска хаба); `make check` — pytest + lint и тесты фронта; `cd web && npm run dev` — разработка с прокси на тестовый хаб `bash web/scripts/test-hub.sh` (:8791); `cd web && npm run e2e` — Playwright; `cd web && npm run golden` — перегенерировать эталоны доски из `links.html@3931a88` (только при смене фикстур). - `uv run jira-hub import FOLDER` — импорт CSV-пакета `admin/jira-sync`; `uv run jira-hub mcp [--project <метка>]` — MCP по stdio (с привязкой сессии к проекту); `uv run jira-hub tokens` — показать токены. - `uv run jira-hub agent publish PATH` — опубликовать версию агента для самообновления (только вверх). diff --git a/web/e2e/feed.spec.ts b/web/e2e/feed.spec.ts index 6958e7d..049aba7 100644 --- a/web/e2e/feed.spec.ts +++ b/web/e2e/feed.spec.ts @@ -16,7 +16,7 @@ async function seedHistory() { expect(r.ok).toBe(true); } -test("лента: событие за сегодня, карточка окном поверх ленты", async ({ page }) => { +test("лента: событие за сегодня, карточка окном поверх ленты, история в карточке", async ({ page }) => { await seedHistory(); await login(page, "/app/feed?project=AWPCA"); const group = page.getByTestId("feed-group").filter({ has: page.getByRole("link", { name: "AURORA-9002", exact: true }) }); @@ -29,6 +29,8 @@ test("лента: событие за сегодня, карточка окно await group.getByRole("link", { name: "AURORA-9002", exact: true }).click(); const d = page.getByRole("dialog"); await expect(d.getByRole("heading", { name: "API свода" })).toBeVisible(); + await expect(d.getByRole("heading", { name: /^История \(\d+\)$/ })).toBeVisible(); + await expect(d.getByTestId("history").getByText("статус «В работе» → «Готово»").first()).toBeVisible(); await page.keyboard.press("Escape"); await expect(page.getByRole("dialog")).toHaveCount(0); await expect(page.getByTestId("page-feed")).toBeVisible(); diff --git a/web/src/issues/IssueCard.test.tsx b/web/src/issues/IssueCard.test.tsx index 5718ab2..8455af3 100644 --- a/web/src/issues/IssueCard.test.tsx +++ b/web/src/issues/IssueCard.test.tsx @@ -450,3 +450,35 @@ describe("карточка задачи", () => { err.mockRestore(); }); }); + +describe("история в карточке", () => { + beforeEach(() => { setToken("tok"); localStorage.clear(); }); + const hist = (n: number) => Array.from({ length: n }, (_, i) => ({ + at: new Date(2026, 9, 20, 9, i).toISOString(), kind: "status" as const, field: "status", author: "Петров", + from: "a", to: `s${i}`, from_value: null, to_value: null, + })); + + it("12 событий: видны 10 последних и «Показать все (12)», после нажатия — все", async () => { + mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { changes: hist(12) }))); + open("/issues/AURORA-9001"); + const sec = await screen.findByTestId("history"); + expect(within(sec).getByRole("heading", { name: "История (12)" })).toBeInTheDocument(); + expect(within(sec).getAllByRole("listitem")).toHaveLength(10); + expect(within(sec).getAllByRole("listitem")[0]).toHaveTextContent(/^20\.10 09:00/); // с датой всегда + await userEvent.click(within(sec).getByRole("button", { name: "Показать все (12)" })); + expect(within(sec).getAllByRole("listitem")).toHaveLength(12); + }); + + it("пустая история — «изменений нет»; без поля changes (старый хаб) — блока нет", async () => { + let body = issue("AURORA-9001", { changes: [] }); + mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, body)); + open("/issues/AURORA-9001"); + const sec = await screen.findByTestId("history"); + expect(within(sec).getByText("изменений нет")).toBeInTheDocument(); + cleanup(); + body = issue("AURORA-9002"); + open("/issues/AURORA-9002"); + await screen.findByRole("heading", { name: "Тема AURORA-9002" }); + expect(screen.queryByTestId("history")).toBeNull(); + }); +}); diff --git a/web/src/issues/IssueCard.tsx b/web/src/issues/IssueCard.tsx index 40c8942..1827131 100644 --- a/web/src/issues/IssueCard.tsx +++ b/web/src/issues/IssueCard.tsx @@ -2,9 +2,10 @@ import { type RefObject, useLayoutEffect, useRef, useState } from "react"; import { ApiError } from "../api/client.ts"; import { useClosedStatuses } from "../api/closed.ts"; import { useIssue, useSettings } from "../api/queries.ts"; -import type { IssueComment, IssueDetail } from "../api/types.ts"; +import type { ChangeEvent, IssueComment, IssueDetail } from "../api/types.ts"; import { JIRA_URL } from "../board/constants.ts"; import { fmtDate, fmtStamp } from "../board/model/util.ts"; +import { EventList } from "../feed/EventList.tsx"; import { groupLinks, isOverdue, todayISO } from "./card.ts"; import { useCommentsHidden } from "./commentsPref.ts"; import { IssueLink } from "./IssueLink.tsx"; @@ -69,6 +70,7 @@ export function IssueCard({ issueKey: key, onClose }: { issueKey: string; onClos } /> {i.description && } + {i.changes && } @@ -178,6 +180,22 @@ function Comments({ items, projectKey }: { items: IssueComment[]; projectKey?: s ); } +const HISTORY_SHOWN = 10; + +// журнал изменений задачи: 10 последних, остальное — по «Показать все» (spec web-app «История в карточке задачи») +function History({ items }: { items: ChangeEvent[] }) { + const [all, setAll] = useState(false); + const shown = all ? items : items.slice(0, HISTORY_SHOWN); + return ( +
+

История ({items.length})

+ {!items.length &&

изменений нет

} + {items.length > 0 && } + {!all && items.length > HISTORY_SHOWN && } +
+ ); +} + function OtherFields({ issue: i }: { issue: IssueDetail }) { const rows: [string, string | null][] = [ ["Тип", i.issue_type], ["Приоритет", i.priority], ["Автор", i.reporter], ["Метки", i.labels.join(", ") || null],