From 815cd3885ea591f6950c6ad0f919d337a7095286 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 25 Oct 2026 20:18:37 +0400 Subject: [PATCH] =?UTF-8?q?feat(web):=20=D0=B7=D0=BD=D0=B0=D1=87=D0=BA?= =?UTF-8?q?=D0=B8=20=D1=82=D0=B8=D0=BF=D0=BE=D0=B2=20=D0=BF=D0=BE=20=D0=BE?= =?UTF-8?q?=D0=B1=D1=80=D0=B0=D0=B7=D1=86=D1=83=20Jira=20=D0=B8=20=D1=84?= =?UTF-8?q?=D0=BE=D1=80=D0=BC=D0=B0=D1=82=20SP=20(#112,=20M-6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - model.ts: fmtSp округляет до двух знаков (M-6); typeKind и isEpic по именам типов Jira без учёта регистра - TypeIcon: квадрат 16 px, вид по typeKind, aria-label — исходное имя типа, без типа — «Тип не указан» - icons.tsx: IconBookmark, IconCircle, IconSquares, IconDot; IconProps.size добавляет 12 - agile.css: фон значка — токены дизайн-системы, символ --color-text-on-primary Co-Authored-By: Claude Haiku 5.5 --- web/src/modules/jira/agile/TypeIcon.test.tsx | 27 ++++++++++++++++++++ web/src/modules/jira/agile/TypeIcon.tsx | 22 +++++++++++----- web/src/modules/jira/agile/agile.css | 10 +++++--- web/src/modules/jira/agile/model.test.ts | 19 +++++++++++++- web/src/modules/jira/agile/model.ts | 18 ++++++++++++- web/src/ui/icons.tsx | 6 ++++- 6 files changed, 89 insertions(+), 13 deletions(-) create mode 100644 web/src/modules/jira/agile/TypeIcon.test.tsx diff --git a/web/src/modules/jira/agile/TypeIcon.test.tsx b/web/src/modules/jira/agile/TypeIcon.test.tsx new file mode 100644 index 0000000..11a657e --- /dev/null +++ b/web/src/modules/jira/agile/TypeIcon.test.tsx @@ -0,0 +1,27 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { TypeIcon } from "./TypeIcon.tsx"; + +describe("TypeIcon — значок типа по образцу Jira", () => { + it.each([ + ["Эпик", "epic"], + ["Story", "story"], + ["Задача", "task"], + ["Ошибка", "bug"], + ["Подзадача", "subtask"], + ["Риск", "other"], + ])("«%s» → вид %s: role=img, aria-label — исходное имя, класс agile-type--%s", (type, kind) => { + const { container } = render(); + const el = screen.getByRole("img", { name: type }); + expect(el).toHaveAttribute("aria-label", type); + expect(el).toHaveClass("agile-type", `agile-type--${kind}`); + expect(container.querySelector("svg")).not.toBeNull(); + }); + + it("null — «Тип не указан», не «Задача»", () => { + render(); + const el = screen.getByRole("img", { name: "Тип не указан" }); + expect(el).toHaveAttribute("aria-label", "Тип не указан"); + expect(el).toHaveClass("agile-type--other"); + }); +}); diff --git a/web/src/modules/jira/agile/TypeIcon.tsx b/web/src/modules/jira/agile/TypeIcon.tsx index 8440969..6eba73b 100644 --- a/web/src/modules/jira/agile/TypeIcon.tsx +++ b/web/src/modules/jira/agile/TypeIcon.tsx @@ -1,9 +1,19 @@ -import { IconBoard, IconCritical, IconCheck } from "../../../ui/index.ts"; +import { IconBookmark, IconCheck, IconCircle, IconCritical, IconDot, IconSquares } from "../../../ui/index.ts"; +import { typeKind, type TypeKind } from "./model.ts"; -// значок типа задачи: эпик, ошибка, прочее — задача; тип — в aria-label и title +// значок типа задачи по образцу Jira: эпик — молния, история — закладка, задача — галочка, ошибка — круг, подзадача — два квадрата +const ICONS: Record = { + epic: IconCritical, story: IconBookmark, task: IconCheck, bug: IconCircle, subtask: IconSquares, other: IconDot, +}; + +// тип — в 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 ; + const kind = typeKind(type); + const label = type ?? "Тип не указан"; + const Icon = ICONS[kind]; + return ( + + + + ); } diff --git a/web/src/modules/jira/agile/agile.css b/web/src/modules/jira/agile/agile.css index a984c47..d997592 100644 --- a/web/src/modules/jira/agile/agile.css +++ b/web/src/modules/jira/agile/agile.css @@ -23,10 +23,12 @@ .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(--k-ui)} -.agile-type--bug{color:var(--color-status-error)} -.agile-type--task{color:var(--color-status-success)} +.agile-type{display:inline-flex;align-items:center;justify-content:center;flex:0 0 auto;width:16px;height:16px;border-radius:var(--radius-xs); + color:var(--color-text-on-primary);background:var(--color-text-secondary)} +.agile-type--epic{background:var(--k-ui)} +.agile-type--story{background:var(--color-status-success)} +.agile-type--task,.agile-type--subtask{background:var(--color-action-primary)} +.agile-type--bug{background:var(--color-status-error)} .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)} diff --git a/web/src/modules/jira/agile/model.test.ts b/web/src/modules/jira/agile/model.test.ts index 1dca927..334b7f4 100644 --- a/web/src/modules/jira/agile/model.test.ts +++ b/web/src/modules/jira/agile/model.test.ts @@ -1,6 +1,6 @@ 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"; +import { boardState, daysLeft, fmtDay, fmtRange, fmtSp, isEpic, jiraDate, tasksWord, typeKind } 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 }, @@ -29,6 +29,23 @@ describe("даты и числа экранов доски", () => { expect([fmtSp(null), fmtSp(3), fmtSp(2.5)]).toEqual(["—", "3", "2.5"]); expect([1, 2, 5, 11, 21, 73].map(tasksWord)).toEqual(["задача", "задачи", "задач", "задач", "задача", "задачи"]); }); + it("fmtSp — до двух знаков без хвостов (M-6)", () => { + expect([fmtSp(0.1 + 0.2), fmtSp(1 / 3), fmtSp(0)]).toEqual(["0.3", "0.33", "0"]); + }); + it("typeKind — имена типов Jira, без учёта регистра", () => { + const kinds = (types: (string | null)[]) => types.map(typeKind); + expect(kinds(["Эпик", "epic", "Фича"])).toEqual(["epic", "epic", "epic"]); + expect(kinds(["История", "Story"])).toEqual(["story", "story"]); + expect(kinds(["Задача", "Task"])).toEqual(["task", "task"]); + expect(kinds(["Ошибка", "BUG"])).toEqual(["bug", "bug"]); + expect(kinds(["Подзадача", "Sub-task", "Subtask"])).toEqual(["subtask", "subtask", "subtask"]); + expect(kinds([null, "", "Риск"])).toEqual(["other", "other", "other"]); + }); + it("isEpic — по виду типа", () => { + expect(isEpic({ issue_type: "Фича" })).toBe(true); + expect(isEpic({ issue_type: null })).toBe(false); + expect(isEpic({ issue_type: "История" })).toBe(false); + }); 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"); diff --git a/web/src/modules/jira/agile/model.ts b/web/src/modules/jira/agile/model.ts index 758b8d9..1c15fbe 100644 --- a/web/src/modules/jira/agile/model.ts +++ b/web/src/modules/jira/agile/model.ts @@ -22,7 +22,23 @@ export function daysLeft(endIso: string | null, now: Date): number | null { return Math.round((midnight(end) - midnight(now)) / 86_400_000); } -export const fmtSp = (n: number | null): string => (n === null ? "—" : String(n)); +// очки до двух знаков без хвостов: 0.1+0.2 → «0.3», 1/3 → «0.33» (M-6) +export const fmtSp = (n: number | null): string => (n === null ? "—" : String(Number(n.toFixed(2)))); + +// вид типа задачи для значка и логики (имена Jira, без учёта регистра); прочее и пустое — other +export type TypeKind = "epic" | "story" | "task" | "bug" | "subtask" | "other"; +const TYPE_KINDS: [RegExp, TypeKind][] = [ + [/^(Эпик|Epic|Фича)$/i, "epic"], + [/^(История|Story)$/i, "story"], + [/^(Задача|Task)$/i, "task"], + [/^(Ошибка|Bug)$/i, "bug"], + [/^(Подзадача|Sub-task|Subtask)$/i, "subtask"], +]; +export function typeKind(type: string | null): TypeKind { + const name = (type ?? "").trim(); + return TYPE_KINDS.find(([re]) => re.test(name))?.[1] ?? "other"; +} +export const isEpic = (i: { issue_type: string | null }): boolean => typeKind(i.issue_type) === "epic"; // «1 задача», «2 задачи», «5 задач» (R-24) export function tasksWord(n: number): string { diff --git a/web/src/ui/icons.tsx b/web/src/ui/icons.tsx index a154a2d..2e55193 100644 --- a/web/src/ui/icons.tsx +++ b/web/src/ui/icons.tsx @@ -1,7 +1,7 @@ import type { ReactNode } from "react"; // иконки дизайн-системы: React-SVG 24x24, штрих currentColor 1.5; без dangerouslySetInnerHTML -export type IconProps = { size?: 16 | 20 }; +export type IconProps = { size?: 12 | 16 | 20 }; function make(children: ReactNode) { return ({ size = 20 }: IconProps) => ( @@ -47,3 +47,7 @@ export const IconBook = make(<>); export const IconBacklog = make(<>); export const IconKanban = make(<>); +export const IconBookmark = make(<>); +export const IconCircle = make(<>); +export const IconSquares = make(<>); +export const IconDot = make(<>);