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:
Andrey Epifancev 2026-10-25 20:21:39 +04:00
parent 815cd3885e
commit a29df27da9
4 changed files with 455 additions and 0 deletions

View 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);
});
});

View 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 {
// хранилище недоступно или переполнено — вид просто не запоминается
}
}

View 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]);
});
});

View 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 }))];
}