feat(web): данные карточки — комментарии, группы связей, просрочка, состояние окна (#37)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-15 13:06:49 +04:00
parent a03550f079
commit 092e7b32ed
9 changed files with 186 additions and 14 deletions

View File

@ -35,11 +35,17 @@ export type Note = Record<NoteField, string | null> & { 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";

View File

@ -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 = <T,>() => { let resolve!: (v: T) => void; const promise = new Promise<T>((r) => { resolve = r; }); return { promise, resolve }; };
function open(path: string, client = makeQueryClient()) {

View File

@ -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<string, string>;
const labels = { ...(settings.status_labels as Record<string, string>), "Снято": "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");
});
});

38
web/src/issues/card.ts Normal file
View File

@ -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<string, string> = { "блокирует начало": "начало", "блокирует завершение": "завершение" };
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<IssueDetail, "links_out" | "links_in">, linkTypes: Record<string, string>): [LinkGroupId, string, CardLink[]][] {
const logical = new Map(Object.entries(linkTypes).map(([name, jira]) => [jira, name]));
const groups: Record<LinkGroupId, CardLink[]> = { 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<string, string> | undefined): string | null =>
(fact && statusLabels?.[fact]) || null;
// R-2: просрочено — срок раньше сегодня, а статус по факту не «закрыт» (классы fact-done, fact-cancelled)
export function isOverdue(due: string, fact: string | null | undefined, statusLabels: Record<string, string> | 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")}`;

View File

@ -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];
}

View File

@ -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 });
});
});

22
web/src/issues/modal.ts Normal file
View File

@ -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 };
}

View File

@ -0,0 +1,19 @@
import type { IssueDetail, Note } from "../api/types.ts";
export const note = (over: Partial<Note> = {}): 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> = {}): 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,
});

View File

@ -3,6 +3,12 @@ import { cleanup } from "@testing-library/react";
import { afterEach } from "vitest";
import { saveBoardScroll } from "../board/scrollMemory.ts";
// jsdom 29 не умеет showModal/close у <dialog>: открытость — атрибут 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);