From 092e7b32ed818ba0632ed80796ca6e0384567a85 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 15 Oct 2026 13:06:49 +0400 Subject: [PATCH] =?UTF-8?q?feat(web):=20=D0=B4=D0=B0=D0=BD=D0=BD=D1=8B?= =?UTF-8?q?=D0=B5=20=D0=BA=D0=B0=D1=80=D1=82=D0=BE=D1=87=D0=BA=D0=B8=20?= =?UTF-8?q?=E2=80=94=20=D0=BA=D0=BE=D0=BC=D0=BC=D0=B5=D0=BD=D1=82=D0=B0?= =?UTF-8?q?=D1=80=D0=B8=D0=B8,=20=D0=B3=D1=80=D1=83=D0=BF=D0=BF=D1=8B=20?= =?UTF-8?q?=D1=81=D0=B2=D1=8F=D0=B7=D0=B5=D0=B9,=20=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D1=81=D1=80=D0=BE=D1=87=D0=BA=D0=B0,=20=D1=81=D0=BE=D1=81?= =?UTF-8?q?=D1=82=D0=BE=D1=8F=D0=BD=D0=B8=D0=B5=20=D0=BE=D0=BA=D0=BD=D0=B0?= =?UTF-8?q?=20(#37)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- web/src/api/types.ts | 10 ++++-- web/src/issues/IssuePage.test.tsx | 13 +------- web/src/issues/card.test.ts | 55 +++++++++++++++++++++++++++++++ web/src/issues/card.ts | 38 +++++++++++++++++++++ web/src/issues/commentsPref.ts | 15 +++++++++ web/src/issues/modal.test.ts | 22 +++++++++++++ web/src/issues/modal.ts | 22 +++++++++++++ web/src/test/issueFixtures.ts | 19 +++++++++++ web/src/test/setup.ts | 6 ++++ 9 files changed, 186 insertions(+), 14 deletions(-) create mode 100644 web/src/issues/card.test.ts create mode 100644 web/src/issues/card.ts create mode 100644 web/src/issues/commentsPref.ts create mode 100644 web/src/issues/modal.test.ts create mode 100644 web/src/issues/modal.ts create mode 100644 web/src/test/issueFixtures.ts diff --git a/web/src/api/types.ts b/web/src/api/types.ts index c8ca895..da86f18 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -35,11 +35,17 @@ export type Note = Record & { key: string; updated_at: export interface IssueCommand { id: number; batch_id: number; type: string; status: string; finished_at: string | null; error: string | null; } +export interface IssueLinkOut { target_key: string; link_type: string; summary: string | null; status: string | null } +export interface IssueLinkIn { source_key: string; link_type: string; summary: string | null; status: string | null } +// комментарий задачи из Jira (зеркало хаба, агент v10+): текст и время — как в Jira +export interface IssueComment { comment_id: string; author: string | null; created: string | null; updated: string | null; body: string } export interface IssueDetail extends IssueRow { - links_out: { target_key: string; link_type: string }[]; - links_in: { source_key: string; link_type: string }[]; + epic_summary: string | null; + links_out: IssueLinkOut[]; + links_in: IssueLinkIn[]; note: Note | null; commands: IssueCommand[]; + comments: IssueComment[]; } export type BatchStatus = "queued" | "running" | "done" | "cancelled"; diff --git a/web/src/issues/IssuePage.test.tsx b/web/src/issues/IssuePage.test.tsx index 801f37e..9cda5fe 100644 --- a/web/src/issues/IssuePage.test.tsx +++ b/web/src/issues/IssuePage.test.tsx @@ -8,20 +8,9 @@ import { makeQueryClient } from "../api/queryClient.ts"; import { setToken } from "../auth/token.ts"; import settings from "../board/__fixtures__/settings.json"; import { json, mockFetch } from "../test/fetchMock.ts"; +import { issue, note } from "../test/issueFixtures.ts"; import { IssuePage } from "./IssuePage.tsx"; -const note = (over = {}) => ({ key: "AURORA-9001", fact_status: "В работе", work_kind: "разработка", done_text: "схема", - rest_text: "API", owner_note: null, updated_at: "2026-10-14T09:00:00+00:00", updated_by: "chat", ...over }); -const issue = (key: string, over = {}) => ({ - key, id: "1", issue_type: "Задача", summary: `Тема ${key}`, description: "строка 1\nстрока 2", status: "В работе", - priority: "Высокий", assignee: "Иванов Иван Иванович", reporter: "me", duedate: null, planned_start: null, - planned_end: "2026-10-20", epic_key: "AURORA-9100", labels: ["kind-dev"], sprint: "AWPCA_Спринт 2", created: null, - jira_updated: null, fetched_at: "", neighbor: false, - links_out: [{ target_key: "AURORA-9002", link_type: "ASUPR_Блокер_Начала" }], - links_in: [{ source_key: "AURORA-9003", link_type: "Duplicate" }], - note: note({ key }), commands: [{ id: 5, batch_id: 2, type: "ensure_fields", status: "done", finished_at: "2026-10-14T09:00:00+00:00", error: null }], - ...over, -}); const deferred = () => { let resolve!: (v: T) => void; const promise = new Promise((r) => { resolve = r; }); return { promise, resolve }; }; function open(path: string, client = makeQueryClient()) { diff --git a/web/src/issues/card.test.ts b/web/src/issues/card.test.ts new file mode 100644 index 0000000..9f3bd9b --- /dev/null +++ b/web/src/issues/card.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import settings from "../board/__fixtures__/settings.json"; +import { factClass, groupLinks, isOverdue, todayISO } from "./card.ts"; + +const types = settings.link_types as Record; +const labels = { ...(settings.status_labels as Record), "Снято": "fact-cancelled" }; +const out = (k: string, t: string, s: string | null = `Тема ${k}`, st: string | null = "В работе") => ({ target_key: k, link_type: t, summary: s, status: st }); +const inn = (k: string, t: string, s: string | null = `Тема ${k}`, st: string | null = "В работе") => ({ source_key: k, link_type: t, summary: s, status: st }); + +describe("groupLinks", () => { + it("блокеры по направлению, связанные без повторов, прочие с направлением; порядок групп и ключей", () => { + const g = groupLinks({ + links_out: [out("A-10", "ASUPR_Блокер_Завершения"), out("A-2", "ASUPR_Блокер_Начала"), out("A-5", "ASUPR_Связанные"), + out("A-7", "Duplicate")], + links_in: [inn("A-3", "ASUPR_Блокер_Начала"), inn("A-5", "ASUPR_Связанные"), inn("A-8", "Duplicate", null, null)], + }, types); + expect(g.map(([id, title]) => [id, title])).toEqual([["depends", "Зависит от"], ["blocks", "Блокирует"], + ["related", "Связана с"], ["other", "Прочие"]]); + expect(g[0][2]).toEqual([{ key: "A-3", summary: "Тема A-3", status: "В работе", tag: "начало" }]); + expect(g[1][2].map((l) => [l.key, l.tag])).toEqual([["A-2", "начало"], ["A-10", "завершение"]]); // ключи как числа + expect(g[2][2].map((l) => l.key)).toEqual(["A-5"]); + expect(g[3][2]).toEqual([{ key: "A-7", summary: "Тема A-7", status: "В работе", tag: "→ Duplicate" }, + { key: "A-8", summary: null, status: null, tag: "← Duplicate" }]); + }); + + it("пустые группы не возвращаются; без link_types всё — «Прочие» с именем Jira", () => { + expect(groupLinks({ links_out: [], links_in: [] }, types)).toEqual([]); + const g = groupLinks({ links_out: [out("A-2", "ASUPR_Блокер_Начала")], links_in: [] }, {}); + expect(g).toEqual([["other", "Прочие", [{ key: "A-2", summary: "Тема A-2", status: "В работе", tag: "→ ASUPR_Блокер_Начала" }]]]); + }); + + it("логический тип из настроек вне трёх известных — «Прочие» с логическим именем", () => { + const g = groupLinks({ links_out: [out("A-2", "X_Дубль")], links_in: [] }, { ...types, "дублирует": "X_Дубль" }); + expect(g[0][2][0].tag).toBe("→ дублирует"); + }); +}); + +describe("просрочка и класс статуса по факту", () => { + it("класс из status_labels", () => { + expect(factClass("Готово — закрыть", labels)).toBe("fact-done"); + expect(factClass("свой статус", labels)).toBeNull(); + expect(factClass(null, labels)).toBeNull(); + }); + it("срок раньше сегодня и не закрыта по факту — просрочено (R-2)", () => { + expect(isOverdue("2026-10-14", "В работе", labels, "2026-10-15")).toBe(true); + expect(isOverdue("2026-10-14", null, labels, "2026-10-15")).toBe(true); + expect(isOverdue("2026-10-15", "В работе", labels, "2026-10-15")).toBe(false); + expect(isOverdue("2026-10-14", "Готово — закрыть", labels, "2026-10-15")).toBe(false); + expect(isOverdue("2026-10-14", "Снято", labels, "2026-10-15")).toBe(false); + expect(isOverdue("", "В работе", labels, "2026-10-15")).toBe(false); + }); + it("todayISO — местная дата YYYY-MM-DD", () => { + expect(todayISO(new Date(2026, 0, 5, 23, 30))).toBe("2026-01-05"); + }); +}); diff --git a/web/src/issues/card.ts b/web/src/issues/card.ts new file mode 100644 index 0000000..9dbab91 --- /dev/null +++ b/web/src/issues/card.ts @@ -0,0 +1,38 @@ +import type { IssueDetail } from "../api/types.ts"; + +export type LinkGroupId = "depends" | "blocks" | "related" | "other"; +export interface CardLink { key: string; summary: string | null; status: string | null; tag: string | null } + +const GROUPS: [LinkGroupId, string][] = [["depends", "Зависит от"], ["blocks", "Блокирует"], ["related", "Связана с"], ["other", "Прочие"]]; +const BLOCK_TAGS: Record = { "блокирует начало": "начало", "блокирует завершение": "завершение" }; +const byKey = (a: CardLink, b: CardLink) => + a.key.localeCompare(b.key, "ru", { numeric: true }) || (a.tag ?? "").localeCompare(b.tag ?? "", "ru"); + +// связи карточки по смыслу (design 17): логическое имя типа — из link_types настроек (имя типа Jira → логическое) +export function groupLinks(d: Pick, linkTypes: Record): [LinkGroupId, string, CardLink[]][] { + const logical = new Map(Object.entries(linkTypes).map(([name, jira]) => [jira, name])); + const groups: Record = { depends: [], blocks: [], related: [], other: [] }; + const add = (dir: "out" | "in", key: string, jiraType: string, summary: string | null, status: string | null) => { + const name = logical.get(jiraType); + if (name && BLOCK_TAGS[name]) groups[dir === "in" ? "depends" : "blocks"].push({ key, summary, status, tag: BLOCK_TAGS[name] }); + else if (name === "связанные") { if (!groups.related.some((l) => l.key === key)) groups.related.push({ key, summary, status, tag: null }); } + else groups.other.push({ key, summary, status, tag: `${dir === "out" ? "→" : "←"} ${name ?? jiraType}` }); + }; + for (const l of d.links_out) add("out", l.target_key, l.link_type, l.summary, l.status); + for (const l of d.links_in) add("in", l.source_key, l.link_type, l.summary, l.status); + return GROUPS.filter(([id]) => groups[id].length).map(([id, title]) => [id, title, groups[id].sort(byKey)]); +} + +const CLOSED_FACT = new Set(["fact-done", "fact-cancelled"]); +export const factClass = (fact: string | null | undefined, statusLabels: Record | undefined): string | null => + (fact && statusLabels?.[fact]) || null; + +// R-2: просрочено — срок раньше сегодня, а статус по факту не «закрыт» (классы fact-done, fact-cancelled) +export function isOverdue(due: string, fact: string | null | undefined, statusLabels: Record | undefined, today: string): boolean { + if (!due || due >= today) return false; + const cls = factClass(fact, statusLabels); + return !(cls && CLOSED_FACT.has(cls)); +} + +export const todayISO = (d = new Date()): string => + `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; diff --git a/web/src/issues/commentsPref.ts b/web/src/issues/commentsPref.ts new file mode 100644 index 0000000..16c0647 --- /dev/null +++ b/web/src/issues/commentsPref.ts @@ -0,0 +1,15 @@ +import { useState } from "react"; + +// выбор «комментарии скрыты» — один на все карточки, в браузере (design 19); без localStorage — показаны +export const COMMENTS_HIDDEN_KEY = "jira-hub-comments-hidden"; +const read = (): boolean => { try { return localStorage.getItem(COMMENTS_HIDDEN_KEY) === "1"; } catch { return false; } }; + +export function useCommentsHidden(): [boolean, (hidden: boolean) => void] { + const [hidden, setHidden] = useState(read); + const set = (v: boolean) => { + setHidden(v); + try { if (v) localStorage.setItem(COMMENTS_HIDDEN_KEY, "1"); else localStorage.removeItem(COMMENTS_HIDDEN_KEY); } + catch { /* хранилище недоступно: выбор живёт до закрытия карточки */ } + }; + return [hidden, set]; +} diff --git a/web/src/issues/modal.test.ts b/web/src/issues/modal.test.ts new file mode 100644 index 0000000..7aa61d8 --- /dev/null +++ b/web/src/issues/modal.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { BOARD_CARD_STATE, cardState, LIST_BACKGROUND, openCardState } from "./modal.ts"; + +describe("состояние окна в истории", () => { + it("со страницы — фон = адрес страницы, глубина 1", () => { + expect(openCardState({ pathname: "/issues", search: "?sprint=S", state: null })) + .toEqual({ background: { pathname: "/issues", search: "?sprint=S" }, depth: 1, direct: false }); + }); + it("из окна — тот же фон, глубина + 1, признак прямого адреса сохраняется", () => { + const s = { background: LIST_BACKGROUND, depth: 0, direct: true }; + expect(openCardState({ pathname: "/issues/A-1", search: "", state: s })).toEqual({ background: LIST_BACKGROUND, depth: 1, direct: true }); + expect(openCardState({ pathname: "/issues/A-2", search: "", state: BOARD_CARD_STATE })) + .toEqual({ background: { pathname: "/board", search: "" }, depth: 2, direct: false }); + }); + it("чужое или битое state — не состояние окна", () => { + expect(cardState(null)).toBeNull(); + expect(cardState({ background: "/issues", depth: 1 })).toBeNull(); + expect(cardState({ background: { pathname: "/issues", search: "" }, depth: "1" })).toBeNull(); + expect(cardState({ background: { pathname: "/issues", search: "" }, depth: 1 })) + .toEqual({ background: { pathname: "/issues", search: "" }, depth: 1, direct: false }); + }); +}); diff --git a/web/src/issues/modal.ts b/web/src/issues/modal.ts new file mode 100644 index 0000000..3a418b5 --- /dev/null +++ b/web/src/issues/modal.ts @@ -0,0 +1,22 @@ +// состояние окна карточки в записи истории (design 10–12): страница под окном, сколько записей заняли карточки +// поверх неё, открыто ли окно прямым адресом +export interface Background { pathname: string; search: string } +export interface CardState { background: Background; depth: number; direct: boolean } + +export const LIST_BACKGROUND: Background = { pathname: "/issues", search: "" }; +// доска не хранит состояние в адресе: «ⓘ» всегда открывает окно поверх /board +export const BOARD_CARD_STATE: CardState = { background: { pathname: "/board", search: "" }, depth: 1, direct: false }; + +export function cardState(s: unknown): CardState | null { + const c = s as { background?: { pathname?: unknown; search?: unknown }; depth?: unknown; direct?: unknown } | null; + if (!c || typeof c !== "object" || !c.background || typeof c.background !== "object") return null; + const { pathname, search } = c.background; + if (typeof pathname !== "string" || typeof search !== "string" || typeof c.depth !== "number") return null; + return { background: { pathname, search }, depth: c.depth, direct: c.direct === true }; +} + +// state ссылки на карточку: со страницы — фон = её адрес, глубина 1; из окна — тот же фон, глубина + 1 +export function openCardState(loc: { pathname: string; search: string; state: unknown }): CardState { + const cur = cardState(loc.state); + return cur ? { ...cur, depth: cur.depth + 1 } : { background: { pathname: loc.pathname, search: loc.search }, depth: 1, direct: false }; +} diff --git a/web/src/test/issueFixtures.ts b/web/src/test/issueFixtures.ts new file mode 100644 index 0000000..5e13c9d --- /dev/null +++ b/web/src/test/issueFixtures.ts @@ -0,0 +1,19 @@ +import type { IssueDetail, Note } from "../api/types.ts"; + +export const note = (over: Partial = {}): Note => ({ + key: "AURORA-9001", fact_status: "В работе", work_kind: "разработка", done_text: "схема", rest_text: "API", + owner_note: null, updated_at: "2026-10-14T09:00:00+00:00", updated_by: "chat", ...over, +}); + +export const issue = (key: string, over: Partial = {}): IssueDetail => ({ + key, id: "1", issue_type: "Задача", summary: `Тема ${key}`, description: "строка 1\nстрока 2", status: "В работе", + priority: "Высокий", assignee: "Иванов Иван Иванович", reporter: "me", duedate: null, planned_start: null, + planned_end: "2026-10-20", epic_key: "AURORA-9100", epic_summary: "Тестовый эпик", labels: ["kind-dev"], + sprint: "AWPCA_Спринт 2", created: null, jira_updated: null, fetched_at: "", neighbor: false, + links_out: [{ target_key: "AURORA-9002", link_type: "ASUPR_Блокер_Начала", summary: "API свода", status: "Новая" }], + links_in: [{ source_key: "AURORA-9003", link_type: "Duplicate", summary: "Макет экрана", status: "Готово" }], + note: note({ key }), + commands: [{ id: 5, batch_id: 2, type: "ensure_fields", status: "done", finished_at: "2026-10-14T09:00:00+00:00", error: null }], + comments: [], + ...over, +}); diff --git a/web/src/test/setup.ts b/web/src/test/setup.ts index 04f5acd..e2181ae 100644 --- a/web/src/test/setup.ts +++ b/web/src/test/setup.ts @@ -3,6 +3,12 @@ import { cleanup } from "@testing-library/react"; import { afterEach } from "vitest"; import { saveBoardScroll } from "../board/scrollMemory.ts"; +// jsdom 29 не умеет showModal/close у : открытость — атрибут open, как в браузере +if (typeof HTMLDialogElement !== "undefined" && !HTMLDialogElement.prototype.showModal) { + HTMLDialogElement.prototype.showModal = function showModal(this: HTMLDialogElement) { this.setAttribute("open", ""); }; + HTMLDialogElement.prototype.close = function close(this: HTMLDialogElement) { this.removeAttribute("open"); }; +} + afterEach(() => { cleanup(); saveBoardScroll(null);