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(<>>);