feat(web): выборка доски, критический путь, проблемы, счётчики фильтров (#21)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-14 22:02:31 +04:00
parent 251b950b57
commit 7065cbeaef
6 changed files with 244 additions and 0 deletions

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

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

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

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

View 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 "Нет задач для показа.";
}

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