feat(web): модель фильтров экранов доски и их запоминание (#112)
Чистая модель без UI: фильтры (q, исполнитель, эпик, скрыть выполненные), списки опций, сводка эпиков, объединение задач спринтов, выбор спринтов и разбор параметра sprint. Вид страниц хранится в localStorage под ключом sdlc-hub-agile по метке проекта и странице; испорченное хранилище и ошибки доступа дают значения по умолчанию. Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
815cd3885e
commit
a29df27da9
79
web/src/modules/jira/agile/agilePref.test.ts
Normal file
79
web/src/modules/jira/agile/agilePref.test.ts
Normal file
@ -0,0 +1,79 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { DEFAULT_VIEW, type AgileView } from "./filters.ts";
|
||||
import { AGILE_KEY, readView, saveView } from "./agilePref.ts";
|
||||
|
||||
const view = (over: Partial<AgileView> = {}): AgileView => ({ ...DEFAULT_VIEW, ...over });
|
||||
const stored = () => JSON.parse(localStorage.getItem(AGILE_KEY) ?? "null");
|
||||
|
||||
beforeEach(() => localStorage.clear());
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
describe("agilePref", () => {
|
||||
it("ключ хранилища — sdlc-hub-agile", () => {
|
||||
expect(AGILE_KEY).toBe("sdlc-hub-agile");
|
||||
});
|
||||
|
||||
it("нет ключа → DEFAULT_VIEW", () => {
|
||||
expect(readView("AWPCA", "backlog")).toEqual(DEFAULT_VIEW);
|
||||
});
|
||||
|
||||
it("запись AWPCA/backlog не меняет AWPCA/sprint и STOCKS/backlog", () => {
|
||||
saveView("STOCKS", "backlog", view({ assignee: "petrov" }));
|
||||
saveView("AWPCA", "sprint", view({ hideDone: true }));
|
||||
saveView("AWPCA", "backlog", view({ epic: "AURORA-10", collapsed: ["AURORA-10"] }));
|
||||
expect(readView("AWPCA", "backlog")).toEqual(view({ epic: "AURORA-10", collapsed: ["AURORA-10"] }));
|
||||
expect(readView("AWPCA", "sprint")).toEqual(view({ hideDone: true }));
|
||||
expect(readView("STOCKS", "backlog")).toEqual(view({ assignee: "petrov" }));
|
||||
expect(readView("AWPCA", "kanban")).toEqual(DEFAULT_VIEW);
|
||||
});
|
||||
|
||||
it("запись не хранит поиск q", () => {
|
||||
saveView("AWPCA", "backlog", { ...view(), q: "секрет" } as AgileView);
|
||||
expect(stored().AWPCA.backlog).not.toHaveProperty("q");
|
||||
});
|
||||
|
||||
it("мусор: «oops» → DEFAULT_VIEW", () => {
|
||||
localStorage.setItem(AGILE_KEY, "oops");
|
||||
expect(readView("AWPCA", "backlog")).toEqual(DEFAULT_VIEW);
|
||||
});
|
||||
|
||||
it("мусор: «[1]» → DEFAULT_VIEW", () => {
|
||||
localStorage.setItem(AGILE_KEY, "[1]");
|
||||
expect(readView("AWPCA", "backlog")).toEqual(DEFAULT_VIEW);
|
||||
});
|
||||
|
||||
it("мусор: {\"AWPCA\": 5} → DEFAULT_VIEW", () => {
|
||||
localStorage.setItem(AGILE_KEY, JSON.stringify({ AWPCA: 5 }));
|
||||
expect(readView("AWPCA", "backlog")).toEqual(DEFAULT_VIEW);
|
||||
});
|
||||
|
||||
it("поле неверного типа — дефолт поля, годные поля сохраняются", () => {
|
||||
localStorage.setItem(AGILE_KEY, JSON.stringify({ AWPCA: { backlog: { collapsed: "x", hideDone: "yes", epic: "AURORA-10" } } }));
|
||||
const v = readView("AWPCA", "backlog");
|
||||
expect(v.collapsed).toEqual([]);
|
||||
expect(v.hideDone).toBe(false);
|
||||
expect(v.epic).toBe("AURORA-10");
|
||||
});
|
||||
|
||||
it("массив с не-строками отбрасывается по элементам", () => {
|
||||
localStorage.setItem(AGILE_KEY, JSON.stringify({ AWPCA: { kanban: { hiddenColumns: ["Готово", 3, null] } } }));
|
||||
expect(readView("AWPCA", "kanban").hiddenColumns).toEqual(["Готово"]);
|
||||
});
|
||||
|
||||
it("epicsPanel: булево сохраняется; не-булево — дефолт true", () => {
|
||||
localStorage.setItem(AGILE_KEY, JSON.stringify({ AWPCA: { backlog: { epicsPanel: false } } }));
|
||||
expect(readView("AWPCA", "backlog").epicsPanel).toBe(false);
|
||||
localStorage.setItem(AGILE_KEY, JSON.stringify({ AWPCA: { backlog: { epicsPanel: 0 } } }));
|
||||
expect(readView("AWPCA", "backlog").epicsPanel).toBe(true);
|
||||
});
|
||||
|
||||
it("saveView не бросает, если setItem бросает", () => {
|
||||
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { throw new Error("quota"); });
|
||||
expect(() => saveView("AWPCA", "backlog", view({ hideDone: true }))).not.toThrow();
|
||||
});
|
||||
|
||||
it("readView → DEFAULT_VIEW, если getItem бросает", () => {
|
||||
vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => { throw new Error("blocked"); });
|
||||
expect(readView("AWPCA", "backlog")).toEqual(DEFAULT_VIEW);
|
||||
});
|
||||
});
|
||||
55
web/src/modules/jira/agile/agilePref.ts
Normal file
55
web/src/modules/jira/agile/agilePref.ts
Normal file
@ -0,0 +1,55 @@
|
||||
// запоминание вида страниц доски в localStorage: { "<метка>": { "backlog"|"sprint"|"kanban": AgileView } }
|
||||
// любая ошибка хранилища — значения по умолчанию, наружу не выходит
|
||||
import { DEFAULT_VIEW, type AgilePage, type AgileView } from "./filters.ts";
|
||||
|
||||
export const AGILE_KEY = "sdlc-hub-agile";
|
||||
|
||||
type Obj = Record<string, unknown>;
|
||||
const isObj = (v: unknown): v is Obj => typeof v === "object" && v !== null && !Array.isArray(v);
|
||||
const str = (v: unknown, d: string): string => (typeof v === "string" ? v : d);
|
||||
const bool = (v: unknown, d: boolean): boolean => (typeof v === "boolean" ? v : d);
|
||||
const strArr = (v: unknown): string[] => (Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : []);
|
||||
|
||||
// поле за полем: неверный тип — значение по умолчанию, годные поля сохраняются
|
||||
function parseView(raw: unknown): AgileView {
|
||||
if (!isObj(raw)) return { ...DEFAULT_VIEW, collapsed: [], hiddenColumns: [] };
|
||||
return {
|
||||
assignee: str(raw.assignee, DEFAULT_VIEW.assignee),
|
||||
epic: str(raw.epic, DEFAULT_VIEW.epic),
|
||||
hideDone: bool(raw.hideDone, DEFAULT_VIEW.hideDone),
|
||||
collapsed: strArr(raw.collapsed),
|
||||
hiddenColumns: strArr(raw.hiddenColumns),
|
||||
epicsPanel: bool(raw.epicsPanel, DEFAULT_VIEW.epicsPanel),
|
||||
};
|
||||
}
|
||||
|
||||
function readRoot(): Obj {
|
||||
const raw = localStorage.getItem(AGILE_KEY);
|
||||
if (raw === null) return {};
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
return isObj(parsed) ? parsed : {};
|
||||
}
|
||||
|
||||
export function readView(label: string, page: AgilePage): AgileView {
|
||||
try {
|
||||
const project = readRoot()[label];
|
||||
return parseView(isObj(project) ? project[page] : undefined);
|
||||
} catch {
|
||||
return parseView(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
export function saveView(label: string, page: AgilePage, v: AgileView): void {
|
||||
try {
|
||||
const root = readRoot();
|
||||
const project = isObj(root[label]) ? root[label] : {};
|
||||
const view: AgileView = {
|
||||
assignee: v.assignee, epic: v.epic, hideDone: v.hideDone,
|
||||
collapsed: v.collapsed, hiddenColumns: v.hiddenColumns, epicsPanel: v.epicsPanel,
|
||||
};
|
||||
root[label] = { ...project, [page]: view };
|
||||
localStorage.setItem(AGILE_KEY, JSON.stringify(root));
|
||||
} catch {
|
||||
// хранилище недоступно или переполнено — вид просто не запоминается
|
||||
}
|
||||
}
|
||||
218
web/src/modules/jira/agile/filters.test.ts
Normal file
218
web/src/modules/jira/agile/filters.test.ts
Normal file
@ -0,0 +1,218 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AgileIssue, AgileResponse, AgileSprint } from "../api/types.ts";
|
||||
import {
|
||||
DEFAULT_VIEW, NONE, assigneeOptions, byRank, epicEntries, hasFilters, matches, parseSprintIds, selectableSprints, sprintIssues,
|
||||
} from "./filters.ts";
|
||||
|
||||
const iss = (key: string, over: Partial<AgileIssue> = {}): AgileIssue => ({
|
||||
key, summary: `Тема ${key}`, issue_type: "Задача", status: "В работе", priority: null, assignee: null,
|
||||
epic_key: null, story_points: null, rank: "0|a", done: false, ...over,
|
||||
});
|
||||
const sprint = (over: Partial<AgileSprint>): AgileSprint => ({
|
||||
id: 1, name: "S", state: "active", start_date: "2026-10-12T09:00:00.000+0300", end_date: "2026-10-26T09:00:00.000+0300",
|
||||
complete_date: null, goal: null, count: 0, count_done: 0, unestimated: 0, points: 0, points_done: 0, issues: [], ...over,
|
||||
});
|
||||
const F = { q: "", assignee: "", epic: "", hideDone: false };
|
||||
|
||||
describe("matches", () => {
|
||||
const a = iss("AURORA-1", { summary: "Тема про отчёт", assignee: "ivanov", epic_key: "AURORA-10" });
|
||||
it("пустой фильтр пропускает всё", () => {
|
||||
expect(matches(iss("X-1", { done: true }), F)).toBe(true);
|
||||
});
|
||||
it("q — подстрока без учёта регистра по key и summary, пробелы по краям отбрасываются", () => {
|
||||
expect(matches(iss("AURORA-1"), { ...F, q: " aurora-1 " })).toBe(true);
|
||||
expect(matches(iss("AURORA-10"), { ...F, q: " aurora-1 " })).toBe(true);
|
||||
expect(matches(iss("AURORA-2"), { ...F, q: " aurora-1 " })).toBe(false);
|
||||
expect(matches(a, { ...F, q: "тема" })).toBe(true);
|
||||
expect(matches(a, { ...F, q: "ОТЧЁТ" })).toBe(true);
|
||||
expect(matches(a, { ...F, q: "нет такого" })).toBe(false);
|
||||
expect(matches(iss("X-1", { summary: null }), { ...F, q: "x-1" })).toBe(true);
|
||||
});
|
||||
it("assignee: NONE — только null; точное совпадение иначе", () => {
|
||||
expect(matches(iss("A-1", { assignee: null }), { ...F, assignee: NONE })).toBe(true);
|
||||
expect(matches(iss("A-1", { assignee: "" }), { ...F, assignee: NONE })).toBe(false);
|
||||
expect(matches(iss("A-1", { assignee: "ivanov" }), { ...F, assignee: NONE })).toBe(false);
|
||||
expect(matches(a, { ...F, assignee: "ivanov" })).toBe(true);
|
||||
expect(matches(a, { ...F, assignee: "petrov" })).toBe(false);
|
||||
});
|
||||
it("epic: NONE — только epic_key === null; точное совпадение иначе", () => {
|
||||
expect(matches(iss("A-1", { epic_key: null }), { ...F, epic: NONE })).toBe(true);
|
||||
expect(matches(a, { ...F, epic: NONE })).toBe(false);
|
||||
expect(matches(a, { ...F, epic: "AURORA-10" })).toBe(true);
|
||||
expect(matches(a, { ...F, epic: "AURORA-11" })).toBe(false);
|
||||
});
|
||||
it("hideDone скрывает выполненные", () => {
|
||||
expect(matches(iss("A-1", { done: true }), { ...F, hideDone: true })).toBe(false);
|
||||
expect(matches(iss("A-1"), { ...F, hideDone: true })).toBe(true);
|
||||
});
|
||||
it("условия складываются по И", () => {
|
||||
expect(matches(a, { q: "aurora", assignee: "ivanov", epic: "AURORA-10", hideDone: true })).toBe(true);
|
||||
expect(matches(a, { q: "aurora", assignee: "ivanov", epic: "AURORA-11", hideDone: true })).toBe(false);
|
||||
expect(matches(iss("A-1", { done: true, assignee: "ivanov" }), { ...F, assignee: "ivanov", hideDone: true })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasFilters", () => {
|
||||
it("пустой фильтр — false, любое поле — true", () => {
|
||||
expect(hasFilters(F)).toBe(false);
|
||||
expect(hasFilters({ ...F, q: "x" })).toBe(true);
|
||||
expect(hasFilters({ ...F, q: " " })).toBe(false);
|
||||
expect(hasFilters({ ...F, assignee: "ivanov" })).toBe(true);
|
||||
expect(hasFilters({ ...F, epic: NONE })).toBe(true);
|
||||
expect(hasFilters({ ...F, hideDone: true })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DEFAULT_VIEW и NONE", () => {
|
||||
it("значения по ТЗ", () => {
|
||||
expect(DEFAULT_VIEW).toEqual({ assignee: "", epic: "", hideDone: false, collapsed: [], hiddenColumns: [], epicsPanel: true });
|
||||
expect(NONE).toBe("-");
|
||||
});
|
||||
});
|
||||
|
||||
describe("assigneeOptions", () => {
|
||||
it("«все», «Без исполнителя», затем исполнители по алфавиту ru", () => {
|
||||
const issues = [iss("A-1", { assignee: "Яковлев" }), iss("A-2", { assignee: null }), iss("A-3", { assignee: "Афанасьев" }), iss("A-4", { assignee: "Афанасьев" })];
|
||||
expect(assigneeOptions(issues, "")).toEqual([
|
||||
{ value: "", label: "все" },
|
||||
{ value: NONE, label: "Без исполнителя" },
|
||||
{ value: "Афанасьев", label: "Афанасьев" },
|
||||
{ value: "Яковлев", label: "Яковлев" },
|
||||
]);
|
||||
});
|
||||
it("current, которого нет среди исполнителей, — в конце с подписью = значению", () => {
|
||||
const opts = assigneeOptions([iss("A-1", { assignee: "ivanov" })], "gone");
|
||||
expect(opts.at(-1)).toEqual({ value: "gone", label: "gone" });
|
||||
expect(opts.map((o) => o.value)).toEqual(["", NONE, "ivanov", "gone"]);
|
||||
});
|
||||
it("current, который есть, не дублируется", () => {
|
||||
const opts = assigneeOptions([iss("A-1", { assignee: "ivanov" })], "ivanov");
|
||||
expect(opts.map((o) => o.value)).toEqual(["", NONE, "ivanov"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("epicEntries", () => {
|
||||
// сценарий «Фильтр по эпику»: AURORA-10 — 3 в бэклоге и 2 в активном спринте → count 5
|
||||
const epic = iss("AURORA-10", { issue_type: "Эпик", summary: "Большой эпик", done: false, rank: "0|b" });
|
||||
const data = (over: Partial<AgileResponse> = {}): AgileResponse => ({
|
||||
board: null,
|
||||
sprints: [
|
||||
sprint({ id: 1, state: "active", issues: [
|
||||
iss("AURORA-1", { epic_key: "AURORA-10" }), iss("AURORA-2", { epic_key: "AURORA-10" }),
|
||||
] }),
|
||||
sprint({ id: 2, state: "future", issues: [] }),
|
||||
],
|
||||
backlog: [
|
||||
iss("AURORA-20", { epic_key: "AURORA-10" }), iss("AURORA-21", { epic_key: "AURORA-10" }), iss("AURORA-22", { epic_key: "AURORA-10" }),
|
||||
iss("AURORA-23"), iss("AURORA-24"), iss("AURORA-25"), iss("AURORA-26"), iss("AURORA-27"),
|
||||
],
|
||||
board_issues: null, ...over,
|
||||
});
|
||||
|
||||
it("сценарий: строка эпика из бэклога, count 5 (3 бэклог + 2 активный спринт)", () => {
|
||||
const d = data({ backlog: [epic, ...data().backlog!] });
|
||||
const e = epicEntries(d);
|
||||
expect(e).toEqual([{ key: "AURORA-10", title: "Большой эпик", count: 5, done: false }]);
|
||||
});
|
||||
|
||||
it("эпик выполнен — done true", () => {
|
||||
const d = data({ backlog: [{ ...epic, done: true }] });
|
||||
expect(epicEntries(d)[0]).toMatchObject({ key: "AURORA-10", done: true });
|
||||
});
|
||||
|
||||
it("строки эпиков из спринтов и бэклога — без повторов по key", () => {
|
||||
const d = data({
|
||||
sprints: [sprint({ id: 1, state: "active", issues: [epic] })],
|
||||
backlog: [epic],
|
||||
});
|
||||
expect(epicEntries(d).map((e) => e.key)).toEqual(["AURORA-10"]);
|
||||
});
|
||||
|
||||
it("эпик, на который ссылаются, но строки нет — в конце, title = ключ, done = false", () => {
|
||||
const d = data({ backlog: [epic, iss("AURORA-40", { epic_key: "AURORA-99" })] });
|
||||
const e = epicEntries(d);
|
||||
expect(e.map((x) => x.key)).toEqual(["AURORA-10", "AURORA-99"]);
|
||||
expect(e[1]).toEqual({ key: "AURORA-99", title: "AURORA-99", count: 1, done: false });
|
||||
});
|
||||
|
||||
it("задачи прошлых спринтов не считаются в count; эпики в count не входят", () => {
|
||||
const d = data({
|
||||
sprints: [
|
||||
sprint({ id: 1, state: "active", issues: [iss("AURORA-1", { epic_key: "AURORA-10" })] }),
|
||||
sprint({ id: 9, state: "closed", issues: [iss("AURORA-90", { epic_key: "AURORA-10" }), iss("AURORA-91", { epic_key: "AURORA-10" })] }),
|
||||
],
|
||||
backlog: [epic],
|
||||
});
|
||||
expect(epicEntries(d)).toEqual([{ key: "AURORA-10", title: "Большой эпик", count: 1, done: false }]);
|
||||
});
|
||||
|
||||
it("задача в двух активных/будущих местах считается один раз", () => {
|
||||
const shared = iss("AURORA-1", { epic_key: "AURORA-10" });
|
||||
const d = data({
|
||||
sprints: [sprint({ id: 1, state: "active", issues: [shared] }), sprint({ id: 2, state: "future", issues: [shared] })],
|
||||
backlog: [epic, shared],
|
||||
});
|
||||
expect(epicEntries(d)[0].count).toBe(1);
|
||||
});
|
||||
|
||||
it("пустые данные — пустой список", () => {
|
||||
expect(epicEntries({ board: null, sprints: [], backlog: null, board_issues: null })).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("byRank", () => {
|
||||
it("по rank строкой, null в конце, затем по key", () => {
|
||||
const list = [iss("B-1", { rank: null }), iss("B-3", { rank: "0|b" }), iss("B-2", { rank: "0|a" }), iss("B-0", { rank: null })];
|
||||
expect(list.sort(byRank).map((i) => i.key)).toEqual(["B-2", "B-3", "B-0", "B-1"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sprintIssues", () => {
|
||||
it("задача в двух спринтах — одна запись; rank null в конце", () => {
|
||||
const moved = iss("AURORA-1", { rank: "0|a" });
|
||||
const res = sprintIssues([
|
||||
sprint({ id: 1, state: "active", issues: [moved, iss("AURORA-2", { rank: null })] }),
|
||||
sprint({ id: 2, state: "future", issues: [moved, iss("AURORA-3", { rank: "0|b" })] }),
|
||||
]);
|
||||
expect(res.map((i) => i.key)).toEqual(["AURORA-1", "AURORA-3", "AURORA-2"]);
|
||||
});
|
||||
it("пустой список спринтов — пусто", () => {
|
||||
expect(sprintIssues([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("selectableSprints", () => {
|
||||
it("закрытые отброшены; активные в порядке входа, затем будущие по start_date; без даты — последним", () => {
|
||||
const res = selectableSprints([
|
||||
sprint({ id: 1, state: "closed" }),
|
||||
sprint({ id: 2, state: "future", start_date: null }),
|
||||
sprint({ id: 3, state: "future", start_date: "2026-11-05T09:00:00.000+0300" }),
|
||||
sprint({ id: 4, state: "active" }),
|
||||
sprint({ id: 5, state: "future", start_date: "2026-11-01T09:00:00.000+0300" }),
|
||||
]);
|
||||
expect(res.map((s) => s.id)).toEqual([4, 5, 3, 2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseSprintIds", () => {
|
||||
const sprints = [
|
||||
sprint({ id: 101, state: "closed" }),
|
||||
sprint({ id: 102, state: "active" }),
|
||||
sprint({ id: 103, state: "future" }),
|
||||
];
|
||||
it("id из строки, только среди selectableSprints", () => {
|
||||
expect(parseSprintIds("102,999", sprints)).toEqual([102]);
|
||||
expect(parseSprintIds("101", sprints)).toEqual([]);
|
||||
});
|
||||
it("порядок — как в selectableSprints, не как в строке", () => {
|
||||
expect(parseSprintIds("103,102", sprints)).toEqual([102, 103]);
|
||||
});
|
||||
it("«abc», null и пустая строка — все активные", () => {
|
||||
expect(parseSprintIds("abc", sprints)).toEqual([102]);
|
||||
expect(parseSprintIds(null, sprints)).toEqual([102]);
|
||||
expect(parseSprintIds("", sprints)).toEqual([102]);
|
||||
});
|
||||
it("будущий спринт по явному id", () => {
|
||||
expect(parseSprintIds("103", sprints)).toEqual([103]);
|
||||
});
|
||||
});
|
||||
103
web/src/modules/jira/agile/filters.ts
Normal file
103
web/src/modules/jira/agile/filters.ts
Normal file
@ -0,0 +1,103 @@
|
||||
// модель фильтров экранов доски (Спринт, Бэклог, Kanban): чистые функции без React и хранилища
|
||||
import type { AgileIssue, AgileResponse, AgileSprint } from "../api/types.ts";
|
||||
import { isEpic, jiraDate } from "./model.ts";
|
||||
|
||||
export type AgilePage = "backlog" | "sprint" | "kanban";
|
||||
|
||||
// сохраняемый вид страницы (хранилище — agilePref.ts); поиск q и выбор спринтов сюда не входят
|
||||
export interface AgileView {
|
||||
assignee: string; epic: string; hideDone: boolean; collapsed: string[]; hiddenColumns: string[]; epicsPanel: boolean;
|
||||
}
|
||||
export const DEFAULT_VIEW: AgileView = { assignee: "", epic: "", hideDone: false, collapsed: [], hiddenColumns: [], epicsPanel: true };
|
||||
|
||||
// значение «Без исполнителя» / «Без эпика»
|
||||
export const NONE = "-";
|
||||
|
||||
export interface IssueFilter { q: string; assignee: string; epic: string; hideDone: boolean }
|
||||
|
||||
// И всех условий; q — подстрока без учёта регистра в key или summary
|
||||
export function matches(i: AgileIssue, f: IssueFilter): boolean {
|
||||
const q = f.q.trim().toLowerCase();
|
||||
if (q && !i.key.toLowerCase().includes(q) && !(i.summary ?? "").toLowerCase().includes(q)) return false;
|
||||
if (f.assignee === NONE ? i.assignee !== null : f.assignee !== "" && i.assignee !== f.assignee) return false;
|
||||
if (f.epic === NONE ? i.epic_key !== null : f.epic !== "" && i.epic_key !== f.epic) return false;
|
||||
if (f.hideDone && i.done) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function hasFilters(f: IssueFilter): boolean {
|
||||
return f.q.trim() !== "" || f.assignee !== "" || f.epic !== "" || f.hideDone;
|
||||
}
|
||||
|
||||
export type Opt = { value: string; label: string };
|
||||
|
||||
// «все», «Без исполнителя», затем исполнители по алфавиту; current, которого нет в данных, — в конце (значение не теряется)
|
||||
export function assigneeOptions(issues: AgileIssue[], current: string): Opt[] {
|
||||
const names = [...new Set(issues.map((i) => i.assignee).filter((a): a is string => !!a))]
|
||||
.sort((a, b) => a.localeCompare(b, "ru"));
|
||||
const opts: Opt[] = [
|
||||
{ value: "", label: "все" },
|
||||
{ value: NONE, label: "Без исполнителя" },
|
||||
...names.map((n) => ({ value: n, label: n })),
|
||||
];
|
||||
if (current && !opts.some((o) => o.value === current)) opts.push({ value: current, label: current });
|
||||
return opts;
|
||||
}
|
||||
|
||||
export interface EpicEntry { key: string; title: string; count: number; done: boolean }
|
||||
|
||||
// порядок по rank (null в конце), затем по key
|
||||
export function byRank(a: AgileIssue, b: AgileIssue): number {
|
||||
if (a.rank === null && b.rank !== null) return 1;
|
||||
if (a.rank !== null && b.rank === null) return -1;
|
||||
if (a.rank !== null && b.rank !== null && a.rank !== b.rank) return a.rank < b.rank ? -1 : 1;
|
||||
return a.key < b.key ? -1 : a.key > b.key ? 1 : 0;
|
||||
}
|
||||
|
||||
// первое вхождение каждого key
|
||||
const uniqByKey = (list: AgileIssue[]): AgileIssue[] => {
|
||||
const seen = new Set<string>();
|
||||
return list.filter((i) => {
|
||||
if (seen.has(i.key)) return false;
|
||||
seen.add(i.key);
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
// объединение задач спринтов без повторов по key, по rank
|
||||
export function sprintIssues(sprints: AgileSprint[]): AgileIssue[] {
|
||||
return uniqByKey(sprints.flatMap((s) => s.issues)).sort(byRank);
|
||||
}
|
||||
|
||||
// активные (в порядке входа), затем будущие по дате начала; без даты — в конце; закрытые не выбираются
|
||||
export function selectableSprints(sprints: AgileSprint[]): AgileSprint[] {
|
||||
const active = sprints.filter((s) => s.state === "active");
|
||||
const future = sprints.filter((s) => s.state === "future");
|
||||
const startMs = (s: AgileSprint) => jiraDate(s.start_date)?.getTime() ?? Infinity;
|
||||
return [...active, ...future.sort((a, b) => startMs(a) - startMs(b))];
|
||||
}
|
||||
|
||||
// id из «101,102» среди selectableSprints, в их порядке; пусто или без годных чисел — все активные
|
||||
export function parseSprintIds(param: string | null, sprints: AgileSprint[]): number[] {
|
||||
const selectable = selectableSprints(sprints);
|
||||
const ids = new Set(
|
||||
(param ?? "").split(",").map((x) => x.trim()).filter((x) => x !== "").map(Number).filter((n) => Number.isInteger(n)),
|
||||
);
|
||||
if (ids.size === 0) return selectable.filter((s) => s.state === "active").map((s) => s.id);
|
||||
return selectable.filter((s) => ids.has(s.id)).map((s) => s.id);
|
||||
}
|
||||
|
||||
// строки-эпики из всех спринтов и бэклога (по rank, без повторов), затем ключи из epic_key без строки (по ключу);
|
||||
// count — задачи-не-эпики с этим epic_key в активных, будущих спринтах и бэклоге, без повторов по key
|
||||
export function epicEntries(d: AgileResponse): EpicEntry[] {
|
||||
const backlog = d.backlog ?? [];
|
||||
const live = [...d.sprints.filter((s) => s.state !== "closed").flatMap((s) => s.issues), ...backlog];
|
||||
const liveTasks = uniqByKey(live.filter((i) => !isEpic(i)));
|
||||
const rows = uniqByKey([...d.sprints.flatMap((s) => s.issues), ...backlog].filter(isEpic)).sort(byRank);
|
||||
const count = (key: string) => liveTasks.filter((t) => t.epic_key === key).length;
|
||||
|
||||
const entries: EpicEntry[] = rows.map((e) => ({ key: e.key, title: e.summary ?? e.key, count: count(e.key), done: e.done }));
|
||||
const known = new Set(rows.map((e) => e.key));
|
||||
const missing = [...new Set(liveTasks.map((t) => t.epic_key).filter((k): k is string => !!k && !known.has(k)))].sort();
|
||||
return [...entries, ...missing.map((key) => ({ key, title: key, count: count(key), done: false }))];
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user