feat(web): значки типов по образцу Jira и формат SP (#112, M-6)

- 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 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-25 20:18:37 +04:00
parent 70cb5ad923
commit 815cd3885e
6 changed files with 89 additions and 13 deletions

View File

@ -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(<TypeIcon type={type} />);
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(<TypeIcon type={null} />);
const el = screen.getByRole("img", { name: "Тип не указан" });
expect(el).toHaveAttribute("aria-label", "Тип не указан");
expect(el).toHaveClass("agile-type--other");
});
});

View File

@ -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<TypeKind, typeof IconCritical> = {
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 <span className={`agile-type agile-type--${kind}`} role="img" aria-label={label} title={label}><Icon size={16} /></span>;
const kind = typeKind(type);
const label = type ?? "Тип не указан";
const Icon = ICONS[kind];
return (
<span className={`agile-type agile-type--${kind}`} role="img" aria-label={label} title={label}>
<Icon size={12} />
</span>
);
}

View File

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

View File

@ -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<AgileBoard>): 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");

View File

@ -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 {

View File

@ -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(<><path d="M5 4.5A1.5 1.5 0 0 1 6.5 3H19v15H6.5A1.5
export const IconSprint = make(<><circle cx="12" cy="12" r="8"/><path d="M12 7.5V12l3 2"/></>);
export const IconBacklog = make(<><rect x="4" y="4" width="16" height="4.5" rx="1.5"/><rect x="4" y="10" width="16" height="4.5" rx="1.5"/><rect x="4" y="16" width="10" height="4" rx="1.5"/></>);
export const IconKanban = make(<><rect x="4" y="4" width="4.5" height="16" rx="1.5"/><rect x="9.75" y="4" width="4.5" height="10" rx="1.5"/><rect x="15.5" y="4" width="4.5" height="13" rx="1.5"/></>);
export const IconBookmark = make(<><path d="M6.5 4h11v16.5L12 16.5 6.5 20.5z"/></>);
export const IconCircle = make(<><circle cx="12" cy="12" r="8"/></>);
export const IconSquares = make(<><rect x="4" y="4" width="7" height="7" rx="1.5"/><rect x="13" y="13" width="7" height="7" rx="1.5"/></>);
export const IconDot = make(<><circle cx="12" cy="12" r="2.5" fill="currentColor"/></>);