From 7065cbeaeff7d67496b8ea6ba96686ea6afa81ea Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 14 Oct 2026 22:02:31 +0400 Subject: [PATCH] =?UTF-8?q?feat(web):=20=D0=B2=D1=8B=D0=B1=D0=BE=D1=80?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=B4=D0=BE=D1=81=D0=BA=D0=B8,=20=D0=BA=D1=80?= =?UTF-8?q?=D0=B8=D1=82=D0=B8=D1=87=D0=B5=D1=81=D0=BA=D0=B8=D0=B9=20=D0=BF?= =?UTF-8?q?=D1=83=D1=82=D1=8C,=20=D0=BF=D1=80=D0=BE=D0=B1=D0=BB=D0=B5?= =?UTF-8?q?=D0=BC=D1=8B,=20=D1=81=D1=87=D1=91=D1=82=D1=87=D0=B8=D0=BA?= =?UTF-8?q?=D0=B8=20=D1=84=D0=B8=D0=BB=D1=8C=D1=82=D1=80=D0=BE=D0=B2=20(#2?= =?UTF-8?q?1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- web/src/board/analysis/analysis.test.ts | 54 +++++++++++++++++++++ web/src/board/analysis/critical.ts | 40 ++++++++++++++++ web/src/board/analysis/facets.ts | 62 +++++++++++++++++++++++++ web/src/board/analysis/problems.ts | 20 ++++++++ web/src/board/analysis/visible.ts | 59 +++++++++++++++++++++++ web/src/test/boardScenarios.ts | 9 ++++ 6 files changed, 244 insertions(+) create mode 100644 web/src/board/analysis/analysis.test.ts create mode 100644 web/src/board/analysis/critical.ts create mode 100644 web/src/board/analysis/facets.ts create mode 100644 web/src/board/analysis/problems.ts create mode 100644 web/src/board/analysis/visible.ts create mode 100644 web/src/test/boardScenarios.ts diff --git a/web/src/board/analysis/analysis.test.ts b/web/src/board/analysis/analysis.test.ts new file mode 100644 index 0000000..8e2b6d6 --- /dev/null +++ b/web/src/board/analysis/analysis.test.ts @@ -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; 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); + }); +}); diff --git a/web/src/board/analysis/critical.ts b/web/src/board/analysis/critical.ts new file mode 100644 index 0000000..d568684 --- /dev/null +++ b/web/src/board/analysis/critical.ts @@ -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; edges: Set } { + const byKey = Object.fromEntries(nodes.map((n) => [n.key, n])); + const out: Record = {}; + nodes.forEach((n) => { out[n.key] = []; }); + links.filter((l) => l.type !== RELATED).forEach((l) => out[l.src].push(l.dst)); + const memo: Record = {}; + // {len, slack, path} от k; запас = сумма (срок зависимой − срок блокера) по рёбрам + const best = (k: string, seen: Set = 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(), edges: new Set() }; + 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; +} diff --git a/web/src/board/analysis/facets.ts b/web/src/board/analysis/facets.ts new file mode 100644 index 0000000..0840e52 --- /dev/null +++ b/web/src/board/analysis/facets.ts @@ -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 = {}, shown: Record = {}; + 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 { + 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 { + 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)); +} diff --git a/web/src/board/analysis/problems.ts b/web/src/board/analysis/problems.ts new file mode 100644 index 0000000..c85495f --- /dev/null +++ b/web/src/board/analysis/problems.ts @@ -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 }; +} diff --git a/web/src/board/analysis/visible.ts b/web/src/board/analysis/visible.ts new file mode 100644 index 0000000..dbe7a95 --- /dev/null +++ b/web/src/board/analysis/visible.ts @@ -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(), edges: new Set() }; + 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 = {}; + 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 "Нет задач для показа."; +} diff --git a/web/src/test/boardScenarios.ts b/web/src/test/boardScenarios.ts new file mode 100644 index 0000000..b0f2d79 --- /dev/null +++ b/web/src/test/boardScenarios.ts @@ -0,0 +1,9 @@ +import { noFilters, type Filters } from "../board/types.ts"; + +// фильтры сценария из __fixtures__/scenarios.json: отсутствующие флаги — false, множества — пустые +export function filtersOf(sc: { filters: Record }): 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; +}