From b5f5efef8502339a01b76acf3b876d7b3573e557 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 19:52:24 +0400 Subject: [PATCH] =?UTF-8?q?=D0=92=D0=B5=D0=B1:=20=D1=81=D1=82=D1=80=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D1=86=D0=B0=20=C2=AB=D0=91=D1=8D=D0=BA=D0=BB=D0=BE?= =?UTF-8?q?=D0=B3=C2=BB=20=D0=B8=20=D0=BE=D0=B1=D1=89=D0=B8=D0=B5=20=D1=87?= =?UTF-8?q?=D0=B0=D1=81=D1=82=D0=B8=20=D1=8D=D0=BA=D1=80=D0=B0=D0=BD=D0=BE?= =?UTF-8?q?=D0=B2=20=D0=B4=D0=BE=D1=81=D0=BA=D0=B8=20(#108)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- web/src/modules/jira/agile/AgileGate.tsx | 37 ++++ .../modules/jira/agile/BacklogPage.test.tsx | 169 ++++++++++++++++++ web/src/modules/jira/agile/BacklogPage.tsx | 75 ++++++++ web/src/modules/jira/agile/IssueRow.tsx | 25 +++ web/src/modules/jira/agile/TypeIcon.tsx | 9 + web/src/modules/jira/agile/agile.css | 38 ++++ web/src/modules/jira/agile/model.test.ts | 38 ++++ web/src/modules/jira/agile/model.ts | 39 ++++ web/src/modules/jira/api/queries.ts | 7 +- web/src/modules/jira/api/types.ts | 19 ++ web/src/modules/jira/index.tsx | 6 +- web/src/modules/jira/projectFields.ts | 6 + web/src/modules/jira/status.ts | 2 +- web/src/modules/modules.test.tsx | 4 +- web/src/ui/icons.tsx | 1 + web/vite.config.ts | 2 +- 16 files changed, 471 insertions(+), 6 deletions(-) create mode 100644 web/src/modules/jira/agile/AgileGate.tsx create mode 100644 web/src/modules/jira/agile/BacklogPage.test.tsx create mode 100644 web/src/modules/jira/agile/BacklogPage.tsx create mode 100644 web/src/modules/jira/agile/IssueRow.tsx create mode 100644 web/src/modules/jira/agile/TypeIcon.tsx create mode 100644 web/src/modules/jira/agile/agile.css create mode 100644 web/src/modules/jira/agile/model.test.ts create mode 100644 web/src/modules/jira/agile/model.ts diff --git a/web/src/modules/jira/agile/AgileGate.tsx b/web/src/modules/jira/agile/AgileGate.tsx new file mode 100644 index 0000000..709d69d --- /dev/null +++ b/web/src/modules/jira/agile/AgileGate.tsx @@ -0,0 +1,37 @@ +import type { ReactNode } from "react"; +import { Link } from "react-router"; +import type { Project } from "../../../api/types.ts"; +import { Button, Notice } from "../../../ui/index.ts"; +import { useAgile } from "../api/queries.ts"; +import type { AgileResponse } from "../api/types.ts"; +import { boardType } from "../projectFields.ts"; +import { RefreshButton } from "../RefreshButton.tsx"; +import { boardState } from "./model.ts"; +import "./agile.css"; + +// общая оболочка экранов доски: тип доски проекта, загрузка, ошибка и три состояния данных (spec web-app «Спринты и бэклог») +export function AgileGate({ project, kind, children }: { project: Project; kind: "scrum" | "kanban"; children: (d: AgileResponse) => ReactNode }) { + const right = boardType(project) === kind; + const q = useAgile(project.label, right); + if (!right) { + return ( +
+ Связи}> + {`У проекта нет ${kind}-доски`} + +
+ ); + } + if (q.isPending) return
Загрузка…
; + if (q.isError) { + return ( +
+ void q.refetch()}>Повторить}>{(q.error as Error).message} +
+ ); + } + const state = boardState(q.data); + if (state === "no-data") return
}>Нужен агент v15 и обновление из Jira
; + if (state === "no-columns") return
}>Не удалось прочитать настройки доски из Jira
; + return <>{children(q.data)}; +} diff --git a/web/src/modules/jira/agile/BacklogPage.test.tsx b/web/src/modules/jira/agile/BacklogPage.test.tsx new file mode 100644 index 0000000..a2be107 --- /dev/null +++ b/web/src/modules/jira/agile/BacklogPage.test.tsx @@ -0,0 +1,169 @@ +import { render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { createMemoryRouter } from "react-router"; +import { beforeEach, describe, expect, it } from "vitest"; +import { App } from "../../../App.tsx"; +import { setToken } from "../../../auth/token.ts"; +import settings from "../board/__fixtures__/settings.json"; +import { routes } from "../../../routes.tsx"; +import { json, mockFetch } from "../../../test/fetchMock.ts"; +import { issue } from "../../../test/issueFixtures.ts"; +import type { AgileIssue, AgileResponse, AgileSprint } from "../api/types.ts"; +import { fmtDay } from "./model.ts"; + +const A = { label: "AWPCA", name: "АРМ КА", board_id: 4216, board_type: "scrum", created_at: "", issues_count: 9, jira_synced_at: null }; +const S = { label: "STOCKS", name: "Запасы", board_id: null, board_type: null, created_at: "", issues_count: 1, jira_synced_at: null }; +const COMPLETE = "2026-10-14T09:00:00.000+0300"; + +const iss = (key: string, over: Partial = {}): AgileIssue => ({ + key, summary: `Тема ${key}`, issue_type: "Задача", status: "В работе", priority: null, assignee: null, + epic_key: null, story_points: null, rank: "0|a", done: false, ...over, +}); +const sprint = (over: Partial): AgileSprint => ({ + id: 1, name: "S", state: "active", start_date: "2026-10-12T09:00:00.000+0300", end_date: "2026-10-26T09:00:00.000+0300", + complete_date: null, goal: null, count: 0, count_done: 0, unestimated: 0, points: 0, points_done: 0, issues: [], ...over, +}); +const many = (n: number, doneN: number) => + Array.from({ length: n }, (_, i) => iss(`AURORA-${2000 + i}`, { done: i < doneN, status: i < doneN ? "Готово" : "В работе" })); +const agile = (over: Partial = {}): AgileResponse => ({ + board: { id: 4216, type: "scrum", columns: [{ name: "К выполнению", statuses: ["Новая"] }, { name: "Готово", statuses: ["Готово"] }], kanban_backlog: null, fetched_at: "2026-10-24T10:00:00+00:00" }, + sprints: [ + sprint({ id: 102, name: "AWPCA_Sprint_3", state: "active", goal: "Закрыть свод", count: 12, count_done: 4, unestimated: 3, points: 25, points_done: 8, + issues: [iss("AURORA-1", { assignee: "Иванов Иван", story_points: 5 }), iss("AURORA-2", { done: true, status: "Готово" })] }), + sprint({ id: 103, name: "AWPCA_Sprint_4", state: "future" }), + sprint({ id: 101, name: "AWPCA_Sprint_2_VGO", state: "closed", complete_date: COMPLETE, count: 73, count_done: 61, points: 25, issues: many(73, 61) }), + ], + backlog: [iss("AURORA-50"), iss("AURORA-51")], + board_issues: null, ...over, +}); + +function hub(opts: { projects?: Response | "hang"; data?: AgileResponse } = {}) { + const agileCalls: URL[] = []; + mockFetch((method, url) => { + const k = `${method} ${url.pathname}`; + if (k === "GET /agile") { agileCalls.push(url); return json(200, opts.data ?? agile()); } + if (k === "GET /projects") return opts.projects === "hang" ? new Promise(() => {}) : opts.projects ?? json(200, { projects: [A, S] }); + if (k === "GET /settings") return json(200, settings); + if (k === "GET /issues") return json(200, { issues: [] }); + if (k === "GET /batches") return json(200, { batches: [] }); + if (k === "GET /agents") return json(200, { agents: [] }); + if (k.startsWith("GET /issues/")) return json(200, issue(decodeURIComponent(url.pathname.split("/").pop()!))); + return json(404, { error: "not_found" }); + }); + return agileCalls; +} +const open = (path: string) => { + const router = createMemoryRouter(routes, { initialEntries: [path], basename: "/app" }); + render(); + return router; +}; +const page = () => screen.findByTestId("page-backlog"); +// страница с загруженными данными: шапка рисуется сразу, секции — после ответа /agile +const loaded = async () => { const p = await page(); await within(p).findByRole("region", { name: "Бэклог" }); return p; }; +const jiraGroup = () => screen.getByRole("group", { name: "Jira" }); +const links = () => within(jiraGroup()).getAllByRole("link").map((l) => l.textContent); + +describe("«Бэклог»", () => { + beforeEach(() => { setToken("tok"); localStorage.clear(); }); + + it("меню scrum-проекта: «Связи», «Бэклог», «Задачи», «Лента»", async () => { // задача 10 добавит «Спринт» + hub(); + open("/app/jira/links?project=AWPCA"); + await within(jiraGroup()).findByRole("link", { name: "Бэклог" }); + expect(links()).toEqual(["Связи", "Бэклог", "Задачи", "Лента"]); + }); + + it("проект без доски: пунктов нет, «У проекта нет scrum-доски» со ссылкой «Связи», запроса /agile нет", async () => { + const calls = hub(); + open("/app/jira/backlog?project=STOCKS"); + expect(await screen.findByText("У проекта нет scrum-доски")).toBeInTheDocument(); + expect(links()).toEqual(["Связи", "Задачи", "Лента"]); + const inPage = within(screen.getByRole("main")); + expect(inPage.getByRole("link", { name: "Связи" })).toHaveAttribute("href", "/app/jira/links?project=STOCKS"); + expect(calls).toHaveLength(0); + }); + + it("пока проекты не загружены, «Бэклог» скрыт", async () => { + hub({ projects: "hang" }); + open("/app/jira/links?project=AWPCA"); + await screen.findByRole("group", { name: "Jira" }); + expect(links()).toEqual(["Связи", "Задачи", "Лента"]); + }); + + it("секции: активный, будущий, «Бэклог», «Прошлые спринты (1)»; заголовок спринта с суммами", async () => { + hub(); + open("/app/jira/backlog?project=AWPCA"); + const p = within(await loaded()); + const secs = p.getAllByRole("region").map((s) => within(s).getAllByRole("heading")[0].textContent); + expect(secs).toEqual(["AWPCA_Sprint_3", "AWPCA_Sprint_4", "Бэклог"]); + const active = p.getByRole("region", { name: "AWPCA_Sprint_3" }); + expect(active).toHaveTextContent("активный"); + expect(active).toHaveTextContent("Закрыть свод"); + expect(active).toHaveTextContent("12 задач"); + expect(active).toHaveTextContent("25 SP (выполнено 8)"); + expect(active).toHaveTextContent("без оценки: 3"); + expect(active).toHaveTextContent(/\d{2}\.\d{2}–\d{2}\.\d{2}/); + const future = p.getByRole("region", { name: "AWPCA_Sprint_4" }); + expect(future).toHaveTextContent("будущий"); + expect(future).toHaveTextContent("Нет задач"); + expect(future).not.toHaveTextContent("без оценки"); + expect(p.getByRole("region", { name: "Бэклог" })).toHaveTextContent("AURORA-50"); + expect(p.getByText("Прошлые спринты (1)")).toBeInTheDocument(); + expect(document.title).toBe("Бэклог · AWPCA · SDLC Hub"); + }); + + it("прошлый спринт: строка и пометки «не выполнено»", async () => { + hub(); + open("/app/jira/backlog?project=AWPCA"); + const p = await loaded(); + const summary = within(p).getByText(`AWPCA_Sprint_2_VGO · закрыт ${fmtDay(COMPLETE)} · выполнено 61 из 73 · 25 SP`); + await userEvent.click(summary); + const det = summary.closest("details")!; + expect(det.querySelectorAll(".agile-row")).toHaveLength(73); + expect(within(det).getAllByText("не выполнено")).toHaveLength(12); + }); + + it("только смотрит: нет draggable, кнопки — только summary и «Обновить из Jira»", async () => { + hub(); + open("/app/jira/backlog?project=AWPCA"); + const p = await loaded(); + expect(document.querySelectorAll('[draggable="true"]').length).toBe(0); + const buttons = within(p).queryAllByRole("button"); + expect(buttons.length).toBeGreaterThan(0); + expect(buttons.every((b) => b.tagName === "SUMMARY" || /Обновить из Jira/.test(b.textContent ?? ""))).toBe(true); + }); + + it("щелчок по строке открывает карточку поверх «Бэклога»", async () => { + hub(); + const router = open("/app/jira/backlog?project=AWPCA"); + const p = await loaded(); + await userEvent.click(within(p).getByText("Тема AURORA-1")); + await screen.findByRole("dialog"); + expect(router.state.location.pathname).toBe("/app/jira/issues/AURORA-1"); + await userEvent.click(within(screen.getByRole("dialog")).getByRole("button", { name: "Закрыть" })); + await waitFor(() => expect(router.state.location.pathname).toBe("/app/jira/backlog")); + }); + + it("нет данных доски — «Нужен агент v15 и обновление из Jira» и «Обновить из Jira»", async () => { + hub({ data: { board: null, sprints: [], backlog: null, board_issues: null } }); + open("/app/jira/backlog?project=AWPCA"); + expect(await screen.findByText("Нужен агент v15 и обновление из Jira")).toBeInTheDocument(); + expect(within(screen.getByRole("main")).getAllByRole("button", { name: "Обновить из Jira" })).toHaveLength(2); // шапка и сообщение (R-20) + }); + + it("колонок нет — «Не удалось прочитать настройки доски из Jira»", async () => { + const d = agile(); d.board!.columns = null; + hub({ data: d }); + open("/app/jira/backlog?project=AWPCA"); + expect(await screen.findByText("Не удалось прочитать настройки доски из Jira")).toBeInTheDocument(); + }); + + it("смена проекта на STOCKS оставляет /app/jira/backlog и показывает «У проекта нет scrum-доски»", async () => { // design #21 + hub(); + const router = open("/app/jira/backlog?project=AWPCA"); + await loaded(); + await userEvent.selectOptions(screen.getByLabelText("Проект"), "STOCKS"); + expect(await screen.findByText("У проекта нет scrum-доски")).toBeInTheDocument(); + expect(router.state.location.pathname).toBe("/app/jira/backlog"); + }); +}); diff --git a/web/src/modules/jira/agile/BacklogPage.tsx b/web/src/modules/jira/agile/BacklogPage.tsx new file mode 100644 index 0000000..2df073e --- /dev/null +++ b/web/src/modules/jira/agile/BacklogPage.tsx @@ -0,0 +1,75 @@ +import { useId } from "react"; +import type { Project } from "../../../api/types.ts"; +import { Badge, PageHeader, Panel } from "../../../ui/index.ts"; +import type { AgileIssue, AgileSprint } from "../api/types.ts"; +import { RefreshButton } from "../RefreshButton.tsx"; +import { AgileGate } from "./AgileGate.tsx"; +import { AgileIssueRow } from "./IssueRow.tsx"; +import { fmtDay, fmtRange, fmtSp, tasksWord } from "./model.ts"; +import "./agile.css"; + +function Issues({ issues, markUndone }: { issues: AgileIssue[]; markUndone?: boolean }) { + if (!issues.length) return

Нет задач

; + return
    {issues.map((i) => )}
; +} + +function SprintSection({ s }: { s: AgileSprint }) { + const id = useId(); + return ( + +
+

{s.name}

+ {s.state === "active" ? "активный" : "будущий"} + {fmtRange(s.start_date, s.end_date)} + {s.goal && {s.goal}} + {`${s.count} ${tasksWord(s.count)}`} + {`${fmtSp(s.points)} SP (выполнено ${fmtSp(s.points_done)})`} + {s.unestimated > 0 && {`без оценки: ${s.unestimated}`}} +
+ +
+ ); +} + +function PastSprint({ s }: { s: AgileSprint }) { + return ( +
+ {`${s.name} · закрыт ${fmtDay(s.complete_date)} · выполнено ${s.count_done} из ${s.count} · ${fmtSp(s.points)} SP`} + +
+ ); +} + +// «Бэклог» (spec web-app): активные и будущие спринты, бэклог, прошлые спринты — только просмотр (R-20) +export function BacklogPage({ project }: { project: Project }) { + const backlogId = useId(); + return ( +
+ } /> + + {(d) => { + const live = [...d.sprints.filter((s) => s.state === "active"), ...d.sprints.filter((s) => s.state === "future")]; + const past = d.sprints.filter((s) => s.state === "closed"); + return ( + <> + {live.map((s) => )} + +
+

Бэклог

+ {`${d.backlog?.length ?? 0} ${tasksWord(d.backlog?.length ?? 0)}`} +
+ +
+ {past.length > 0 && ( +
+ {`Прошлые спринты (${past.length})`} + {past.map((s) => )} +
+ )} + + ); + }} +
+
+ ); +} diff --git a/web/src/modules/jira/agile/IssueRow.tsx b/web/src/modules/jira/agile/IssueRow.tsx new file mode 100644 index 0000000..a8e6f7a --- /dev/null +++ b/web/src/modules/jira/agile/IssueRow.tsx @@ -0,0 +1,25 @@ +import type { MouseEvent } from "react"; +import type { AgileIssue } from "../api/types.ts"; +import { IssueLink, useOpenIssue } from "../issues/IssueLink.tsx"; +import { fmtSp } from "./model.ts"; +import { TypeIcon } from "./TypeIcon.tsx"; + +// строка задачи экранов доски; щелчок (не по ссылке и не при выделении текста) открывает карточку поверх страницы +export function AgileIssueRow({ issue, mark }: { issue: AgileIssue; mark?: string }) { + const open = useOpenIssue(); + const click = (e: MouseEvent) => { + if ((e.target as HTMLElement).closest("a")) return; + if (window.getSelection()?.toString()) return; + open(issue.key); + }; + return ( +
  • + + + {issue.summary ?? "—"} + {issue.assignee ?? "—"} + {fmtSp(issue.story_points)} + {mark && {mark}} +
  • + ); +} diff --git a/web/src/modules/jira/agile/TypeIcon.tsx b/web/src/modules/jira/agile/TypeIcon.tsx new file mode 100644 index 0000000..8440969 --- /dev/null +++ b/web/src/modules/jira/agile/TypeIcon.tsx @@ -0,0 +1,9 @@ +import { IconBoard, IconCritical, IconCheck } from "../../../ui/index.ts"; + +// значок типа задачи: эпик, ошибка, прочее — задача; тип — в aria-label и title +export function TypeIcon({ type }: { type: string | null }) { + const label = type ?? "Задача"; + const kind = /^(Эпик|Epic|Фича)$/i.test(label) ? "epic" : /^(Ошибка|Bug)$/i.test(label) ? "bug" : "task"; + const Icon = kind === "epic" ? IconBoard : kind === "bug" ? IconCritical : IconCheck; + return ; +} diff --git a/web/src/modules/jira/agile/agile.css b/web/src/modules/jira/agile/agile.css new file mode 100644 index 0000000..ffff67e --- /dev/null +++ b/web/src/modules/jira/agile/agile.css @@ -0,0 +1,38 @@ +/* экраны доски (бэклог, спринт, Kanban): только токены дизайн-системы */ +.agile{display:flex;flex-direction:column;gap:var(--space-4);padding-top:var(--space-6)} +.agile .ui-page-header{margin-bottom:0} +.agile__gate{padding-top:var(--space-6)} +.agile .agile__gate{padding-top:0} +.agile__link{color:var(--color-action-primary);font-weight:600;text-decoration:none} +.agile__link:hover{text-decoration:underline} +.agile__empty{margin:0;color:var(--color-text-secondary)} +.agile-section{display:flex;flex-direction:column;gap:var(--space-3);min-width:0} +.agile-section__head{display:flex;flex-wrap:wrap;align-items:center;gap:var(--space-2) var(--space-4)} +.agile-section__title{margin:0;font-size:var(--fs-lg);line-height:var(--lh-lg);font-weight:700} +.agile-section__meta{font-size:var(--fs-body);color:var(--color-text-secondary)} +.agile-section__goal{font-size:var(--fs-body);min-width:0;overflow-wrap:anywhere} +.agile-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column} +.agile-row{display:flex;align-items:center;gap:var(--space-3);min-width:0;padding:var(--space-2) var(--space-1); + border-top:1px solid var(--color-border-default);font-size:var(--fs-body);line-height:var(--lh-body);cursor:pointer} +.agile-row:first-child{border-top:0} +.agile-row:hover{background:var(--color-bg-hover)} +.agile-row.is-done{color:var(--color-text-secondary)} +.agile-row.is-done .agile-row__summary{text-decoration:line-through} +.agile-row__key{flex:0 0 auto;font-family:var(--font-mono);font-size:13px;color:var(--color-action-primary);text-decoration:none} +.agile-row__key:hover{text-decoration:underline} +.agile-row__summary{flex:1 1 auto;min-width:0;overflow-wrap:anywhere} +.agile-row__assignee{flex:0 1 160px;min-width:0;color:var(--color-text-secondary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.agile-row__sp{flex:0 0 32px;text-align:right;color:var(--color-text-secondary)} +.agile-row__mark{flex:0 0 auto;font-size:var(--fs-caption);color:var(--color-status-warning)} +.agile-type{display:inline-flex;flex:0 0 auto;color:var(--color-text-secondary)} +.agile-type--epic{color:var(--color-cat-1)} +.agile-type--bug{color:var(--color-status-error)} +.agile-type--task{color:var(--color-status-success)} +.agile-past{display:flex;flex-direction:column;gap:var(--space-2)} +.agile-past>summary{cursor:pointer;font-weight:600;padding:var(--space-2) 0} +.agile-past__sprint{background:var(--color-bg-surface);border:1px solid var(--color-border-default);border-radius:var(--radius-lg);padding:var(--space-2) var(--space-4)} +.agile-past__sprint>summary{cursor:pointer;padding:var(--space-1) 0} +@container page (max-width: 640px){ + .agile-row{flex-wrap:wrap} + .agile-row__assignee{flex-basis:100%} +} diff --git a/web/src/modules/jira/agile/model.test.ts b/web/src/modules/jira/agile/model.test.ts new file mode 100644 index 0000000..1dca927 --- /dev/null +++ b/web/src/modules/jira/agile/model.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import type { AgileBoard, AgileResponse } from "../api/types.ts"; +import { boardState, daysLeft, fmtDay, fmtRange, fmtSp, jiraDate, tasksWord } from "./model.ts"; + +const resp = (over: Partial): AgileResponse => ({ + board: { id: 4216, type: "scrum", columns: [{ name: "Готово", statuses: ["Готово"] }], kanban_backlog: null, fetched_at: "2026-10-24T10:00:00+00:00", ...over }, + sprints: [], backlog: [], board_issues: null, +}); + +describe("даты и числа экранов доски", () => { + it("время Jira с ±HHMM и ±HH:MM — один день", () => { // Review Focus 1 + expect(fmtDay("2026-10-14T09:00:00.000+0300")).toBe(fmtDay("2026-10-14T09:00:00.000+03:00")); + expect(fmtDay("2026-10-14T09:00:00.000+0300")).toMatch(/^\d{2}\.\d{2}$/); + expect(fmtDay(null)).toBe("—"); expect(fmtDay("мусор")).toBe("—"); + }); + it("jiraDate и fmtRange", () => { + expect(jiraDate("2026-10-14T09:00:00.000+0300")?.getTime()).toBe(Date.parse("2026-10-14T09:00:00.000+03:00")); + expect(jiraDate("мусор")).toBeNull(); + expect(fmtRange(null, null)).toBe("—–—"); + expect(fmtRange("2026-10-14T09:00:00.000+0300", "2026-10-28T09:00:00.000+0300")).toMatch(/^\d{2}\.\d{2}–\d{2}\.\d{2}$/); + }); + it("daysLeft — календарные дни до конца", () => { + const now = new Date(2026, 9, 24, 23, 30); + expect(daysLeft(new Date(2026, 9, 27, 1, 0).toISOString(), now)).toBe(3); + expect(daysLeft(new Date(2026, 9, 23, 12, 0).toISOString(), now)).toBe(-1); + expect(daysLeft(null, now)).toBeNull(); + }); + it("fmtSp, tasksWord", () => { + expect([fmtSp(null), fmtSp(3), fmtSp(2.5)]).toEqual(["—", "3", "2.5"]); + expect([1, 2, 5, 11, 21, 73].map(tasksWord)).toEqual(["задача", "задачи", "задач", "задач", "задача", "задачи"]); + }); + it("boardState — три состояния (design #23)", () => { + expect(boardState({ board: null, sprints: [], backlog: null, board_issues: null })).toBe("no-data"); + expect(boardState(resp({ fetched_at: null, columns: null }))).toBe("no-data"); + expect(boardState(resp({ fetched_at: "2026-10-24T10:00:00+00:00", columns: null }))).toBe("no-columns"); + expect(boardState(resp({}))).toBe("ok"); + }); +}); diff --git a/web/src/modules/jira/agile/model.ts b/web/src/modules/jira/agile/model.ts new file mode 100644 index 0000000..758b8d9 --- /dev/null +++ b/web/src/modules/jira/agile/model.ts @@ -0,0 +1,39 @@ +import type { AgileResponse } from "../api/types.ts"; + +// время Jira приходит и как «+0300» (поля задачи), и как «+03:00» (Agile REST); Date.parse понимает не всякое — приводим к «+03:00» (R-26) +export function jiraDate(iso: string | null): Date | null { + if (!iso) return null; + const d = new Date(iso.replace(/([+-]\d{2})(\d{2})$/, "$1:$2")); + return isNaN(d.getTime()) ? null : d; +} + +const pad = (n: number) => String(n).padStart(2, "0"); +const day = (d: Date) => `${pad(d.getDate())}.${pad(d.getMonth() + 1)}`; + +// «ДД.ММ» в часовом поясе браузера; нет даты или она негодная — «—» +export const fmtDay = (iso: string | null): string => { const d = jiraDate(iso); return d ? day(d) : "—"; }; +export const fmtRange = (start: string | null, end: string | null): string => `${fmtDay(start)}–${fmtDay(end)}`; + +// сколько календарных дней (в поясе браузера) от «сегодня» до конца спринта; отрицательное — срок прошёл +export function daysLeft(endIso: string | null, now: Date): number | null { + const end = jiraDate(endIso); + if (!end) return null; + const midnight = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime(); + return Math.round((midnight(end) - midnight(now)) / 86_400_000); +} + +export const fmtSp = (n: number | null): string => (n === null ? "—" : String(n)); + +// «1 задача», «2 задачи», «5 задач» (R-24) +export function tasksWord(n: number): string { + const m10 = n % 10, m100 = n % 100; + if (m10 === 1 && m100 !== 11) return "задача"; + if (m10 >= 2 && m10 <= 4 && (m100 < 12 || m100 > 14)) return "задачи"; + return "задач"; +} + +// три состояния данных доски (R-19, design #23) +export function boardState(d: AgileResponse): "no-data" | "no-columns" | "ok" { + if (!d.board || d.board.fetched_at === null) return "no-data"; + return d.board.columns === null ? "no-columns" : "ok"; +} diff --git a/web/src/modules/jira/api/queries.ts b/web/src/modules/jira/api/queries.ts index 053eecd..31a20b7 100644 --- a/web/src/modules/jira/api/queries.ts +++ b/web/src/modules/jira/api/queries.ts @@ -1,7 +1,7 @@ import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { api } from "../../../api/client.ts"; import { qk as coreQk } from "../../../api/queries.ts"; -import type { BoardSnapshot, ChangesResponse, IssueDetail, IssueRow, Note, NoteFields } from "./types.ts"; +import type { AgileResponse, BoardSnapshot, ChangesResponse, IssueDetail, IssueRow, Note, NoteFields } from "./types.ts"; // фильтры списка в адресе; hide_closed в адрес не пишется — его выводит список из флажка «скрыть закрытые» (closed=1 — снят) export const ISSUE_FILTER_NAMES = ["sprint", "assignee", "work_kind", "status", "label", "epic", "neighbor", "q"] as const; @@ -12,6 +12,7 @@ export const ISSUE_PAGE = 50; export const qk = { ...coreQk, board: (project: string) => ["board", project] as const, + agile: (project: string) => ["agile", project] as const, boardAll: ["board"] as const, // префикс: все снимки досок сразу (инвалидация) issues: (f: IssueFilters) => ["issues", f] as const, issue: (key: string) => ["issue", key] as const, @@ -62,3 +63,7 @@ export function useSaveNote(key: string) { }, }); } + +// данные экранов доски (бэклог, спринт, Kanban): запрос только для проекта с доской нужного типа (enabled) +export const useAgile = (project: string, enabled = true) => + useQuery({ queryKey: qk.agile(project), enabled, queryFn: () => api.get("/agile", { project }) }); diff --git a/web/src/modules/jira/api/types.ts b/web/src/modules/jira/api/types.ts index e611aa3..ec5951a 100644 --- a/web/src/modules/jira/api/types.ts +++ b/web/src/modules/jira/api/types.ts @@ -71,3 +71,22 @@ export interface IssueDetail extends IssueRow { changes?: ChangeEvent[]; // хаб до журнала изменений (#70) поля не шлёт } + +// ответ GET /agile (доска проекта, спринты, бэклог); поля — как отдаёт хаб +export interface AgileIssue { + key: string; summary: string | null; issue_type: string | null; status: string | null; priority: string | null; + assignee: string | null; epic_key: string | null; story_points: number | null; rank: string | null; done: boolean; +} +export interface BoardColumn { name: string; statuses: string[] } +export interface AgileBoard { + id: number; type: "scrum" | "kanban" | null; columns: BoardColumn[] | null; kanban_backlog: boolean | null; fetched_at: string | null; +} +export type SprintState = "active" | "future" | "closed"; +export interface AgileSprint { + id: number; name: string; state: SprintState; start_date: string | null; end_date: string | null; complete_date: string | null; + goal: string | null; count: number; count_done: number; unestimated: number; points: number; points_done: number; + issues: AgileIssue[]; +} +export interface AgileResponse { + board: AgileBoard | null; sprints: AgileSprint[]; backlog: AgileIssue[] | null; board_issues: AgileIssue[] | null; +} diff --git a/web/src/modules/jira/index.tsx b/web/src/modules/jira/index.tsx index d00e39e..10ded21 100644 --- a/web/src/modules/jira/index.tsx +++ b/web/src/modules/jira/index.tsx @@ -1,11 +1,13 @@ import { Navigate, useLocation, useParams } from "react-router"; import { ProjectGate } from "../../projects/ProjectGate.tsx"; -import { IconBoard, IconHistory, IconList } from "../../ui/index.ts"; +import { IconBacklog, IconBoard, IconHistory, IconList } from "../../ui/index.ts"; import type { HubModule } from "../types.ts"; +import { BacklogPage } from "./agile/BacklogPage.tsx"; import { BoardPage } from "./board/BoardPage.tsx"; import { FeedPage } from "./feed/FeedPage.tsx"; import { useIssueOverlay } from "./issues/overlay.tsx"; import { IssuesPage } from "./issues/IssuesPage.tsx"; +import { boardType } from "./projectFields.ts"; import { jiraStatus } from "./status.ts"; import { Summary } from "./Summary.tsx"; @@ -22,6 +24,7 @@ export const jiraModule: HubModule = { title: "Jira", pages: [ { path: "/jira/links", element: {(p) => } }, + { path: "/jira/backlog", element: {(p) => } }, { path: "/jira/issues", element: {(p) => } }, { path: "/jira/feed", element: {(p) => } }, // прежние адреса (закладки): перенаправление с сохранением строки запроса, без новой записи истории @@ -33,6 +36,7 @@ export const jiraModule: HubModule = { ], nav: [ { to: "/jira/links", label: "Связи", icon: }, + { to: "/jira/backlog", label: "Бэклог", icon: , padded: true, when: (p) => boardType(p) === "scrum" }, { to: "/jira/issues", label: "Задачи", icon: , padded: true }, { to: "/jira/feed", label: "Лента", icon: , padded: true, keepSearch: true }, ], diff --git a/web/src/modules/jira/projectFields.ts b/web/src/modules/jira/projectFields.ts index 42b3a16..335e6a8 100644 --- a/web/src/modules/jira/projectFields.ts +++ b/web/src/modules/jira/projectFields.ts @@ -7,3 +7,9 @@ export type JiraProject = Project & { }; // у проекта в ответе хаба поле модуля есть всегда (null — «ещё не было»); без него (старый ответ, тестовая фикстура) — не проект Jira export const hasJiraSync = (p: Project): p is JiraProject => "jira_synced_at" in p; + +// тип доски проекта по ответу хаба; null — доски нет или тип ещё не известен +export const boardType = (p: Project): "scrum" | "kanban" | null => { + const t = (p as { board_type?: unknown }).board_type; + return t === "scrum" || t === "kanban" ? t : null; +}; diff --git a/web/src/modules/jira/status.ts b/web/src/modules/jira/status.ts index 54c53ec..5ad508c 100644 --- a/web/src/modules/jira/status.ts +++ b/web/src/modules/jira/status.ts @@ -16,6 +16,6 @@ export const jiraStatus: ModuleStatus = { }, // перечитывание активных снимков досок — после своего пакета; показанный файл не заменяется (M-1) invalidate: (qc) => { - for (const key of [qk.boardAll, ["issues"], ["issue"], ["changes"]]) void qc.invalidateQueries({ queryKey: key }); + for (const key of [qk.boardAll, ["issues"], ["issue"], ["changes"], ["agile"]]) void qc.invalidateQueries({ queryKey: key }); }, }; diff --git a/web/src/modules/modules.test.tsx b/web/src/modules/modules.test.tsx index 39b0e5a..5812fc6 100644 --- a/web/src/modules/modules.test.tsx +++ b/web/src/modules/modules.test.tsx @@ -6,12 +6,12 @@ import { MODULES } from "./index.ts"; describe("сборка оболочки из модулей", () => { it("страницы: «Обзор», адреса jira, перенаправления, вики, NotFound последним", () => { - expect(PAGE_ROUTES.map((r) => r.path)).toEqual(["/", "/jira/links", "/jira/issues", "/jira/feed", "/jira/board", "/board", "/issues", "/issues/:key", "/feed", "/wiki", "*"]); + expect(PAGE_ROUTES.map((r) => r.path)).toEqual(["/", "/jira/links", "/jira/backlog", "/jira/issues", "/jira/feed", "/jira/board", "/board", "/issues", "/issues/:key", "/feed", "/wiki", "*"]); }); it("пункты панели: подписи прежние, адреса jira с префиксом, плюс вики", () => { expect(MODULES.flatMap((m) => m.nav).map((n) => [n.label, n.to])).toEqual([ - ["Связи", "/jira/links"], ["Задачи", "/jira/issues"], ["Лента", "/jira/feed"], ["Вики", "/wiki"], + ["Связи", "/jira/links"], ["Бэклог", "/jira/backlog"], ["Задачи", "/jira/issues"], ["Лента", "/jira/feed"], ["Вики", "/wiki"], ]); }); diff --git a/web/src/ui/icons.tsx b/web/src/ui/icons.tsx index bd60fdd..c4e81c2 100644 --- a/web/src/ui/icons.tsx +++ b/web/src/ui/icons.tsx @@ -44,3 +44,4 @@ export const IconImage = make(<>); export const IconHome = make(<>); export const IconBook = make(<>); +export const IconBacklog = make(<>); diff --git a/web/vite.config.ts b/web/vite.config.ts index 8021a41..f9319ce 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -3,7 +3,7 @@ import { defineConfig } from "vitest/config"; // сервер разработки: приложение под /app/, маршруты API — прокси на хаб (по умолчанию тестовый :8791, R-5) const hub = process.env.SDLC_HUB_URL ?? "http://127.0.0.1:8791"; // порт держать равным HUB_PORT в playwright.config.ts -const API = ["/board", "/issues", "/settings", "/agent", "/refresh", "/batches", "/commands", "/sync-runs"]; +const API = ["/board", "/agile", "/issues", "/settings", "/agent", "/refresh", "/batches", "/commands", "/sync-runs"]; export default defineConfig({ base: "/app/",