feat(web): выборка доски, критический путь, проблемы, счётчики фильтров (#21)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
251b950b57
commit
7065cbeaef
54
web/src/board/analysis/analysis.test.ts
Normal file
54
web/src/board/analysis/analysis.test.ts
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { BoardSnapshot } from "../../api/types.ts";
|
||||||
|
import { canon } from "../../test/canon.ts";
|
||||||
|
import { dictsFromSettings } from "../dicts.ts";
|
||||||
|
import settings from "../__fixtures__/settings.json";
|
||||||
|
import scenarios from "../__fixtures__/scenarios.json";
|
||||||
|
import { fromHub } from "../model/fromHub.ts";
|
||||||
|
import { filtersOf } from "../../test/boardScenarios.ts";
|
||||||
|
import { noFilters } from "../types.ts";
|
||||||
|
import { initialSprints, initialWho, sprintItems, whoItems } from "./facets.ts";
|
||||||
|
import { emptyReason, computeVisible } from "./visible.ts";
|
||||||
|
|
||||||
|
const fx = (name: string) => readFileSync(join(import.meta.dirname, "..", "__fixtures__", name), "utf8");
|
||||||
|
const golden = JSON.parse(fx("hub-basic.expected.json"));
|
||||||
|
const { dicts } = dictsFromSettings(settings);
|
||||||
|
const model = fromHub(JSON.parse(fx("hub-basic.board.json")) as BoardSnapshot, dicts);
|
||||||
|
const TODAY = "2026-10-14";
|
||||||
|
|
||||||
|
type Sc = { name: string; filters: Record<string, unknown>; laneOrder?: string[] };
|
||||||
|
|
||||||
|
describe("выборка = links.html@3931a88", () => {
|
||||||
|
for (const sc of scenarios as Sc[]) {
|
||||||
|
it(`computeVisible: ${sc.name}`, () => {
|
||||||
|
const f = filtersOf(sc);
|
||||||
|
const vis = computeVisible(model, f, sc.laneOrder ?? [], TODAY);
|
||||||
|
const g = golden.scenarios[sc.name];
|
||||||
|
expect(canon(vis)).toEqual(g.visible);
|
||||||
|
expect(vis.nodes.length ? null : emptyReason(model, vis, f)).toEqual(g.emptyReason);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("пустое зеркало — своя причина", () => {
|
||||||
|
const empty = fromHub(JSON.parse(fx("hub-empty.board.json")) as BoardSnapshot, dicts);
|
||||||
|
const vis = computeVisible(empty, noFilters(), [], TODAY);
|
||||||
|
expect(emptyReason(empty, vis, noFilters())).toBe(JSON.parse(fx("hub-empty.expected.json")).scenario.emptyReason);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("счётчики и начальный выбор спринтов и исполнителей", () => {
|
||||||
|
// порядок как в старой странице: сначала спринты (счётчики без фильтров), затем исполнители — уже с выбранными спринтами
|
||||||
|
const check = (remembered: unknown, g: { sprintItems: unknown; whoItems: unknown; sprints: unknown; who: unknown }) => {
|
||||||
|
const f = noFilters();
|
||||||
|
expect(canon(sprintItems(model, f).items)).toEqual(g.sprintItems);
|
||||||
|
f.sprints = initialSprints(model, f, remembered);
|
||||||
|
expect(canon(f.sprints)).toEqual(g.sprints);
|
||||||
|
expect(canon(whoItems(model, f).items)).toEqual(g.whoItems);
|
||||||
|
expect(canon(initialWho(model, f, []))).toEqual(g.who);
|
||||||
|
};
|
||||||
|
check(null, golden.facets.default);
|
||||||
|
check([], golden.facets.rememberedAll);
|
||||||
|
check(["AWPCA_Спринт 3", "нет такого"], golden.facets.rememberedSome);
|
||||||
|
});
|
||||||
|
});
|
||||||
40
web/src/board/analysis/critical.ts
Normal file
40
web/src/board/analysis/critical.ts
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import { RELATED } from "../constants.ts";
|
||||||
|
import { dayDiff } from "../model/util.ts";
|
||||||
|
import type { VisLink, VisNode } from "../types.ts";
|
||||||
|
|
||||||
|
interface Best { len: number; slack: number; path: string[] }
|
||||||
|
|
||||||
|
// критический путь: самая длинная цепочка блокировок; при равной длине — с наименьшим запасом по плановым датам окончания
|
||||||
|
export function criticalPath(nodes: VisNode[], links: VisLink[]): { nodes: Set<string>; edges: Set<string> } {
|
||||||
|
const byKey = Object.fromEntries(nodes.map((n) => [n.key, n]));
|
||||||
|
const out: Record<string, string[]> = {};
|
||||||
|
nodes.forEach((n) => { out[n.key] = []; });
|
||||||
|
links.filter((l) => l.type !== RELATED).forEach((l) => out[l.src].push(l.dst));
|
||||||
|
const memo: Record<string, Best> = {};
|
||||||
|
// {len, slack, path} от k; запас = сумма (срок зависимой − срок блокера) по рёбрам
|
||||||
|
const best = (k: string, seen: Set<string> = new Set()): Best => {
|
||||||
|
if (memo[k]) return memo[k];
|
||||||
|
if (seen.has(k)) return { len: 0, slack: 0, path: [k] };
|
||||||
|
seen.add(k);
|
||||||
|
let r: Best = { len: 0, slack: 0, path: [k] };
|
||||||
|
out[k].forEach((d) => {
|
||||||
|
const b = best(d, seen), sl = dayDiff(byKey[k].due, byKey[d].due);
|
||||||
|
const cand = { len: b.len + 1, slack: b.slack + (sl === null ? 0 : Math.max(0, sl)), path: [k, ...b.path] };
|
||||||
|
if (cand.len > r.len || (cand.len === r.len && cand.slack < r.slack)) r = cand;
|
||||||
|
});
|
||||||
|
memo[k] = r;
|
||||||
|
return r;
|
||||||
|
};
|
||||||
|
let top: Best | null = null;
|
||||||
|
nodes.forEach((n) => {
|
||||||
|
const b = best(n.key);
|
||||||
|
if (!top || b.len > top.len || (b.len === top.len && b.slack < top.slack)) top = b;
|
||||||
|
});
|
||||||
|
const res = { nodes: new Set<string>(), edges: new Set<string>() };
|
||||||
|
const t = top as Best | null;
|
||||||
|
if (t && t.len > 0) {
|
||||||
|
t.path.forEach((k) => res.nodes.add(k));
|
||||||
|
for (let i = 0; i + 1 < t.path.length; i++) res.edges.add(t.path[i] + ">" + t.path[i + 1]);
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
62
web/src/board/analysis/facets.ts
Normal file
62
web/src/board/analysis/facets.ts
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
import { shortName, whoKey } from "../model/util.ts";
|
||||||
|
import type { BoardModel, BoardNode, Filters } from "../types.ts";
|
||||||
|
import { passes } from "./visible.ts";
|
||||||
|
|
||||||
|
export interface FacetItem { value: string; label: string; title: string; count: string; cls: string }
|
||||||
|
export type Box = { kind: "none" } | { kind: "text"; text: string } | { kind: "multi" };
|
||||||
|
|
||||||
|
const strList = (v: unknown): string[] => Array.isArray(v) ? v.filter((s): s is string => typeof s === "string") : [];
|
||||||
|
|
||||||
|
// счётчики в выпадашках: «n/всего» — n задач с этим значением проходят два других фильтра задач, всего — в файле.
|
||||||
|
// Порядок пунктов — по n, затем по всему, затем по имени
|
||||||
|
function facetItems(model: BoardModel, f: Filters, field: (n: BoardNode) => string, skip: "sprint" | "who", labelOf: (k: string) => string, cls: (k: string) => string) {
|
||||||
|
const total: Record<string, number> = {}, shown: Record<string, number> = {};
|
||||||
|
model.nodes.filter((n) => !n.stub).forEach((n) => {
|
||||||
|
const k = field(n);
|
||||||
|
total[k] = (total[k] || 0) + 1;
|
||||||
|
if (passes(n, f, skip)) shown[k] = (shown[k] || 0) + 1;
|
||||||
|
});
|
||||||
|
const keys = Object.keys(total).sort((a, b) => Number(a === "") - Number(b === "") || (shown[b] || 0) - (shown[a] || 0) || total[b] - total[a] || a.localeCompare(b, "ru"));
|
||||||
|
const items: FacetItem[] = keys.map((k) => ({
|
||||||
|
value: k, label: labelOf(k),
|
||||||
|
title: `${labelOf(k)}: ${shown[k] || 0} из ${total[k]} задач проходят остальные фильтры`,
|
||||||
|
count: `${shown[k] || 0}/${total[k]}`, cls: cls(k) + (shown[k] ? "" : " zero"),
|
||||||
|
}));
|
||||||
|
return { total, items };
|
||||||
|
}
|
||||||
|
export const sprintItems = (model: BoardModel, f: Filters) =>
|
||||||
|
facetItems(model, f, (n) => n.sprint || "", "sprint", (k) => k || "без спринта", (k) => (k ? "" : "none"));
|
||||||
|
export const whoItems = (model: BoardModel, f: Filters) =>
|
||||||
|
facetItems(model, f, (n) => whoKey(n.who), "who", (k) => (k ? shortName(k) : "не назначен"), (k) => (k ? "" : "none"));
|
||||||
|
|
||||||
|
// что показывать вместо выпадающего списка спринтов
|
||||||
|
export function sprintBox(model: BoardModel): Box {
|
||||||
|
if (!model.nodes.length) return { kind: "none" };
|
||||||
|
if (!model.hasSprint) return { kind: "text", text: "колонки «Спринт» нет — показаны все задачи" };
|
||||||
|
return { kind: "multi" };
|
||||||
|
}
|
||||||
|
// то же для исполнителей
|
||||||
|
export function whoBox(model: BoardModel, f: Filters): Box {
|
||||||
|
if (!model.nodes.length) return { kind: "none" };
|
||||||
|
const names = Object.keys(whoItems(model, f).total);
|
||||||
|
if (!names.length || (names.length === 1 && names[0] === "")) return { kind: "text", text: names.length ? "все задачи без исполнителя" : "колонки «Исполнитель» нет" };
|
||||||
|
return { kind: "multi" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// список спринтов: по умолчанию выбран самый наполненный; «без спринта» — отдельный пункт; выбор помнится; пустой выбор = все спринты
|
||||||
|
export function initialSprints(model: BoardModel, f: Filters, remembered: unknown): Set<string> {
|
||||||
|
if (!model.nodes.length || !model.hasSprint) return new Set();
|
||||||
|
const { total } = sprintItems(model, f);
|
||||||
|
const sel = new Set(strList(remembered).filter((n) => total[n] !== undefined));
|
||||||
|
if (!sel.size && !(Array.isArray(remembered) && remembered.length === 0)) { // пустой сохранённый выбор — осознанное «все спринты»
|
||||||
|
const top = Object.entries(total).filter(([n]) => n).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))[0];
|
||||||
|
if (top) sel.add(top[0]);
|
||||||
|
}
|
||||||
|
return sel;
|
||||||
|
}
|
||||||
|
// список исполнителей: чистый фильтр, на доске только их задачи; соседей добавляет кнопка «+ соседи по связям»
|
||||||
|
export function initialWho(model: BoardModel, f: Filters, remembered: unknown): Set<string> {
|
||||||
|
if (!model.nodes.length || whoBox(model, f).kind !== "multi") return new Set();
|
||||||
|
const { total } = whoItems(model, f);
|
||||||
|
return new Set(strList(remembered).filter((w) => total[w] !== undefined));
|
||||||
|
}
|
||||||
20
web/src/board/analysis/problems.ts
Normal file
20
web/src/board/analysis/problems.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { RELATED } from "../constants.ts";
|
||||||
|
import { dayDiff, fmtDate, notStarted, shortName, whoKey } from "../model/util.ts";
|
||||||
|
import type { ProblemItem, Problems, VisLink, VisNode } from "../types.ts";
|
||||||
|
|
||||||
|
// точки риска по блокирующим связям видимой части доски; today — ГГГГ-ММ-ДД
|
||||||
|
export function findProblems(nodes: VisNode[], links: VisLink[], today: string): Problems {
|
||||||
|
const byKey = Object.fromEntries(nodes.map((n) => [n.key, n]));
|
||||||
|
const dates: ProblemItem[] = [], unassigned: ProblemItem[] = [], late: ProblemItem[] = [], hand: ProblemItem[] = [];
|
||||||
|
links.filter((l) => l.type !== RELATED).forEach((l) => {
|
||||||
|
const a = byKey[l.src], b = byKey[l.dst];
|
||||||
|
if (a.stub || b.stub) return;
|
||||||
|
const sl = dayDiff(a.due, b.due);
|
||||||
|
if (sl !== null && sl <= 0) dates.push({ a, b, text: sl === 0 ? `сроки совпадают (${fmtDate(a.due)}): запаса нет` : `блокер должен закончиться ${fmtDate(a.due)}, а зависимая — уже ${fmtDate(b.due)}` });
|
||||||
|
if (!whoKey(a.who)) unassigned.push({ a, b, text: whoKey(b.who) ? `${shortName(b.who)} ждёт задачу без исполнителя` : "блокер без исполнителя" });
|
||||||
|
const left = dayDiff(today, b.due);
|
||||||
|
if (notStarted(a.status) && left !== null && left <= 7) late.push({ a, b, text: `блокер в статусе «${a.status}», а до срока зависимой ${left < 0 ? "срок прошёл" : left + " дн."}` });
|
||||||
|
if (l.hand && whoKey(a.who) && whoKey(b.who)) hand.push({ a, b, text: `${shortName(b.who)} ждёт ${shortName(a.who)}` });
|
||||||
|
});
|
||||||
|
return { dates, unassigned, late, hand };
|
||||||
|
}
|
||||||
59
web/src/board/analysis/visible.ts
Normal file
59
web/src/board/analysis/visible.ts
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
import { KIND, NO_EPIC, NOT_IN_FILE, NOT_IN_HUB, RELATED } from "../constants.ts";
|
||||||
|
import { whoKey } from "../model/util.ts";
|
||||||
|
import type { BoardModel, BoardNode, Filters, Link, VisLink, VisNode, Visible } from "../types.ts";
|
||||||
|
import { criticalPath } from "./critical.ts";
|
||||||
|
import { findProblems } from "./problems.ts";
|
||||||
|
|
||||||
|
// фильтры задач — пересечение спринта, исполнителя и вида работы; skip — какой из них не учитывать (для счётчиков в выпадашках).
|
||||||
|
// Заглушки «нет в файле» фильтры не проходят: они попадают на доску только как соседи по связям
|
||||||
|
export const passes = (x: BoardNode, f: Filters, skip?: "sprint" | "who" | "kind"): boolean => !x.stub
|
||||||
|
&& (skip === "sprint" || f.sprints.size === 0 || f.sprints.has(x.sprint))
|
||||||
|
&& (skip === "who" || f.who.size === 0 || f.who.has(whoKey(x.who)))
|
||||||
|
&& (skip === "kind" || !f.hideKind.has(KIND[x.kind] || "org"));
|
||||||
|
export const edgeClass = (l: Link): "rel" | "block" => (l.type === RELATED ? "rel" : "block");
|
||||||
|
|
||||||
|
// доска: ядро = задачи, прошедшие фильтры; контекст = их соседи по связям (если включён); стрелки = все связи между карточками на доске
|
||||||
|
// минус скрытые типы и, при «передачах», минус связи внутри одного человека; «скрыть без связей» убирает карточки без оставшихся стрелок.
|
||||||
|
// removed — сколько карточек/стрелок убрал каждый шаг, для строки статуса и объяснения пустой доски
|
||||||
|
export function computeVisible(model: BoardModel, f: Filters, laneOrder: string[], today: string): Visible {
|
||||||
|
const all = model.nodes, byKey = Object.fromEntries(all.map((x) => [x.key, x]));
|
||||||
|
const isHand = (l: Link) => l.type !== RELATED && whoKey(byKey[l.src].who) !== whoKey(byKey[l.dst].who);
|
||||||
|
const core = new Set(all.filter((x) => passes(x, f)).map((x) => x.key));
|
||||||
|
let keep = new Set(core);
|
||||||
|
if (f.neighbors) model.links.forEach((l) => { if (core.has(l.src) || core.has(l.dst)) { keep.add(l.src); keep.add(l.dst); } });
|
||||||
|
const removed = { core: core.size, ctx: keep.size - core.size, edge: 0, loose: 0 };
|
||||||
|
let rawLinks = model.links.filter((l) => keep.has(l.src) && keep.has(l.dst));
|
||||||
|
const allLinks = rawLinks.length;
|
||||||
|
if (f.hideEdge.size) rawLinks = rawLinks.filter((l) => !f.hideEdge.has(edgeClass(l)));
|
||||||
|
if (f.onlyHand) rawLinks = rawLinks.filter(isHand);
|
||||||
|
removed.edge = allLinks - rawLinks.length;
|
||||||
|
if (f.onlyLinked) {
|
||||||
|
const ends = new Set(rawLinks.flatMap((l) => [l.src, l.dst])), before = keep.size;
|
||||||
|
keep = new Set([...keep].filter((k) => ends.has(k)));
|
||||||
|
removed.loose = before - keep.size;
|
||||||
|
}
|
||||||
|
const links: VisLink[] = rawLinks.map((l) => ({ ...l, hand: isHand(l), crit: false }));
|
||||||
|
// ядро сплошной рамкой, соседи пунктиром
|
||||||
|
const nodes: VisNode[] = all.filter((x) => keep.has(x.key)).map((x) => ({ ...x, dim: !core.has(x.key), focus: false, crit: false }));
|
||||||
|
const crit = f.crit ? criticalPath(nodes, links) : { nodes: new Set<string>(), edges: new Set<string>() };
|
||||||
|
nodes.forEach((x) => { x.crit = crit.nodes.has(x.key); });
|
||||||
|
links.forEach((l) => { l.crit = crit.edges.has(l.src + ">" + l.dst); });
|
||||||
|
// дорожки по эпикам: по алфавиту, служебные внизу, затем сохранённый порядок пользователя
|
||||||
|
const byEpic: Record<string, string[]> = {};
|
||||||
|
nodes.forEach((x) => (byEpic[x.epic] = byEpic[x.epic] || []).push(x.key));
|
||||||
|
const tail = (name: string) => name === NO_EPIC || name === NOT_IN_FILE || name === NOT_IN_HUB;
|
||||||
|
const lanes = Object.entries(byEpic).sort((a, b) => Number(tail(a[0])) - Number(tail(b[0])) || a[0].localeCompare(b[0], "ru")).map(([name, keys]) => ({ name, keys }));
|
||||||
|
if (laneOrder.length) lanes.sort((a, b) => { const ia = laneOrder.indexOf(a.name), ib = laneOrder.indexOf(b.name); return (ia < 0 ? 1e9 : ia) - (ib < 0 ? 1e9 : ib); });
|
||||||
|
return { nodes, links, lanes, removed, problems: findProblems(nodes, links, today) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function emptyReason(model: BoardModel, vis: Visible, f: Filters): string {
|
||||||
|
const r = vis.removed;
|
||||||
|
if (!model.nodes.some((n) => !n.stub)) return model.emptyText || (model.stubLane === NOT_IN_HUB ? "В зеркале хаба нет задач." : "В файле нет задач.");
|
||||||
|
if (!r.core) {
|
||||||
|
const active = [f.sprints.size ? "спринт" : "", f.who.size ? "исполнитель" : "", f.hideKind.size ? "виды работ в легенде" : ""].filter(Boolean);
|
||||||
|
return `Фильтры не оставили ни одной задачи (${active.join(", ")}). Ослабьте один из них: «Снять выбор» в выпадашке или щелчок по зачёркнутому виду работы.`;
|
||||||
|
}
|
||||||
|
if (r.loose) return `Все ${r.loose} задач скрыты кнопкой «скрыть без связей»: ${f.onlyHand ? "передач между людьми на доске нет" : "видимых стрелок у них нет"}. Отключите её${f.onlyHand ? " или «передачи»" : f.hideEdge.size ? " или верните тип связи в легенде" : ""}.`;
|
||||||
|
return "Нет задач для показа.";
|
||||||
|
}
|
||||||
9
web/src/test/boardScenarios.ts
Normal file
9
web/src/test/boardScenarios.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { noFilters, type Filters } from "../board/types.ts";
|
||||||
|
|
||||||
|
// фильтры сценария из __fixtures__/scenarios.json: отсутствующие флаги — false, множества — пустые
|
||||||
|
export function filtersOf(sc: { filters: Record<string, unknown> }): Filters {
|
||||||
|
const f = noFilters(), src = sc.filters;
|
||||||
|
for (const k of ["neighbors", "onlyLinked", "onlyHand", "crit", "byDate"] as const) f[k] = !!src[k];
|
||||||
|
for (const k of ["sprints", "who", "hideKind", "hideEdge"] as const) f[k] = new Set((src[k] as string[] | undefined) ?? []);
|
||||||
|
return f;
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user