- 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>
56 lines
2.9 KiB
TypeScript
56 lines
2.9 KiB
TypeScript
import type { AgileResponse } from "../api/types.ts";
|
||
|
||
// время Jira приходит и как «+0300» (поля задачи), и как «+03:00» (Agile REST); Date.parse понимает не всякое — приводим к «+03:00» (R-26)
|
||
export function jiraDate(iso: string | null): Date | null {
|
||
if (!iso) return null;
|
||
const d = new Date(iso.replace(/([+-]\d{2})(\d{2})$/, "$1:$2"));
|
||
return isNaN(d.getTime()) ? null : d;
|
||
}
|
||
|
||
const pad = (n: number) => String(n).padStart(2, "0");
|
||
const day = (d: Date) => `${pad(d.getDate())}.${pad(d.getMonth() + 1)}`;
|
||
|
||
// «ДД.ММ» в часовом поясе браузера; нет даты или она негодная — «—»
|
||
export const fmtDay = (iso: string | null): string => { const d = jiraDate(iso); return d ? day(d) : "—"; };
|
||
export const fmtRange = (start: string | null, end: string | null): string => `${fmtDay(start)}–${fmtDay(end)}`;
|
||
|
||
// сколько календарных дней (в поясе браузера) от «сегодня» до конца спринта; отрицательное — срок прошёл
|
||
export function daysLeft(endIso: string | null, now: Date): number | null {
|
||
const end = jiraDate(endIso);
|
||
if (!end) return null;
|
||
const midnight = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
|
||
return Math.round((midnight(end) - midnight(now)) / 86_400_000);
|
||
}
|
||
|
||
// очки до двух знаков без хвостов: 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 {
|
||
const m10 = n % 10, m100 = n % 100;
|
||
if (m10 === 1 && m100 !== 11) return "задача";
|
||
if (m10 >= 2 && m10 <= 4 && (m100 < 12 || m100 > 14)) return "задачи";
|
||
return "задач";
|
||
}
|
||
|
||
// три состояния данных доски (R-19, design #23)
|
||
export function boardState(d: AgileResponse): "no-data" | "no-columns" | "ok" {
|
||
if (!d.board || d.board.fetched_at === null) return "no-data";
|
||
return d.board.columns === null ? "no-columns" : "ok";
|
||
}
|