From 80cff5bcfb5f6f1849ae3acc66724e1ee615f8b5 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 17 Oct 2026 11:46:22 +0400 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D1=81=D0=BA=D0=B0:=20=D0=B7=D0=B0?= =?UTF-8?q?=D0=BA=D1=80=D1=8B=D1=82=D1=8B=D0=B5=20=D0=B7=D0=B0=D0=B4=D0=B0?= =?UTF-8?q?=D1=87=D0=B8=20=D0=B8=20=D0=B8=D1=85=20=D1=81=D0=B2=D1=8F=D0=B7?= =?UTF-8?q?=D0=B8=20=D0=BF=D1=80=D0=B8=D0=B3=D0=BB=D1=83=D1=88=D0=B5=D0=BD?= =?UTF-8?q?=D1=8B=20(#57)?= 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/BoardPage.tsx | 4 ++- web/src/board/board.css | 3 ++ web/src/board/components/BoardCanvas.test.tsx | 35 +++++++++++++++++++ web/src/board/components/BoardCanvas.tsx | 11 ++++-- web/src/board/components/Card.test.ts | 1 + web/src/board/components/Card.tsx | 7 ++-- web/src/board/components/Edges.tsx | 5 +-- 7 files changed, 57 insertions(+), 9 deletions(-) diff --git a/web/src/board/BoardPage.tsx b/web/src/board/BoardPage.tsx index ecc58c2..ebeddb9 100644 --- a/web/src/board/BoardPage.tsx +++ b/web/src/board/BoardPage.tsx @@ -1,4 +1,5 @@ import { useCallback, useMemo, useRef, useState } from "react"; +import { useClosedStatuses } from "../api/closed.ts"; import { useSettings } from "../api/queries.ts"; import { computeVisible, emptyReason } from "./analysis/visible.ts"; import { BoardCanvas } from "./components/BoardCanvas.tsx"; @@ -17,6 +18,7 @@ import "./board.css"; export function BoardPage() { const settings = useSettings(); + const closedStatuses = useClosedStatuses(); const d = useMemo(() => (settings.data ? dictsFromSettings(settings.data) : null), [settings.data]); const src = useBoardSource(d && !d.missing.length ? d.dicts : null); const prefs = useBoardPrefs(src.model); @@ -76,7 +78,7 @@ export function BoardPage() { {exportText !== null && setExportText(null)} />} prefs.moveLane(vis.lanes.map((l) => l.name), name, to)} onColWidth={prefs.setColWidth} saveRef={saveRef}> {prefs.problems && prefs.toggle("problems")} />} diff --git a/web/src/board/board.css b/web/src/board/board.css index 491ec2e..d67ec3a 100644 --- a/web/src/board/board.css +++ b/web/src/board/board.css @@ -90,6 +90,9 @@ svg.guides line{stroke:var(--guide);stroke-width:1;stroke-dasharray:4 3} .card{position:absolute;width:240px;height:120px;box-sizing:border-box;background:var(--card);border:1px solid var(--line);border-radius:6px;padding:12px 14px;display:flex;flex-direction:column;gap:4px;overflow:hidden;cursor:default;touch-action:none} .card:hover{border-color:var(--accent)} .card.out{border-style:dashed;opacity:.7} +.card.closed{opacity:.4} +.card.closed:hover,.card.closed:focus-within{opacity:1} +.edges path.closed{opacity:.4} .card.crit{box-shadow:0 0 0 2px var(--guide)} .card.focus{box-shadow:0 0 0 2px var(--accent)} .card.drag{cursor:grabbing;border-color:var(--accent);box-shadow:0 6px 18px rgba(0,0,0,.18);z-index:2} diff --git a/web/src/board/components/BoardCanvas.test.tsx b/web/src/board/components/BoardCanvas.test.tsx index 5c0b041..fa3004b 100644 --- a/web/src/board/components/BoardCanvas.test.tsx +++ b/web/src/board/components/BoardCanvas.test.tsx @@ -32,6 +32,41 @@ function draw(over: Partial[0]> = {}) { const board = () => document.querySelector(".board") as HTMLElement; describe("BoardCanvas", () => { + // закрытые (hide-closed): src первой видимой связи — «Готово», её dst — «Снято» (плюс закрытые из фикстуры) + function closedVis() { + const vis = baseProps().vis; + const l = vis.links[0]; + const st: Record = { [l.src]: "Готово", [l.dst]: "Снято" }; + return { vis: { ...vis, nodes: vis.nodes.map((n) => (st[n.key] ? { ...n, status: st[n.key] } : n)) }, l }; + } + const CLOSED = new Set(["Готово", "Снято"]); + + it("закрытые карточки и стрелки, у которых закрыт хоть один конец, — класс closed", () => { + const { vis, l } = closedVis(); + draw({ vis, closedStatuses: CLOSED }); + const keys = vis.nodes.filter((n) => CLOSED.has(n.status)).map((n) => n.key); // в фикстуре ещё AURORA-9006 «Готово» + expect(keys).toEqual(expect.arrayContaining([l.src, l.dst])); + const closedCards = [...board().querySelectorAll(".card.closed[data-key]")].map((e) => e.getAttribute("data-key")).sort(); + expect(closedCards).toEqual([...keys].sort()); + const touching = vis.links.filter((x) => keys.includes(x.src) || keys.includes(x.dst)).length; + expect(document.querySelectorAll("svg.edges > path.closed")).toHaveLength(touching); + expect(document.querySelectorAll("svg.edges > path:not(.closed)").length).toBe(vis.links.length - touching); + expect(document.querySelectorAll(".measure .card.closed")).toHaveLength(0); // слой измерения — без приглушения + }); + + it("без closedStatuses — ничего не приглушено", () => { + draw({ vis: closedVis().vis }); + expect(document.querySelectorAll(".card.closed, svg.edges > path.closed")).toHaveLength(0); + }); + + it("закрытый сосед — и out, и closed", () => { + const { vis, l } = closedVis(); + draw({ vis: { ...vis, nodes: vis.nodes.map((n) => (n.key === l.src ? { ...n, dim: true } : n)) }, closedStatuses: CLOSED }); + const card = board().querySelector(`.card[data-key="${l.src}"]`)!; + expect(card).toHaveClass("out"); + expect(card).toHaveClass("closed"); + }); + it("дорожки по эпикам и карточки задач с заглушкой", () => { draw(); const lanes = [...board().querySelectorAll(".lane-name")].map((e) => e.textContent); diff --git a/web/src/board/components/BoardCanvas.tsx b/web/src/board/components/BoardCanvas.tsx index 4915326..9d14d04 100644 --- a/web/src/board/components/BoardCanvas.tsx +++ b/web/src/board/components/BoardCanvas.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type PointerEvent, type ReactNode } from "react"; +import { isClosed } from "../../api/closed.ts"; import { CW, GRID, MINH, MINW, TOP } from "../constants.ts"; import { alignEdges } from "../layout/align.ts"; import { clampToCol, computeLayout, measuredHeight, place, type Layout, type Placed, type RelBox } from "../layout/layout.ts"; @@ -15,6 +16,7 @@ export interface LayoutStore { laneOrder: string[]; folded: Set; colWidt export interface BoardCanvasProps { vis: Visible; byDate: boolean; snap: boolean; today: string; store: LayoutStore; sprintSel: Set; emptyText: string; + closedStatuses?: Set; // статусы Jira закрытых задач: их карточки и стрелки приглушены (hide-closed), на модель не влияют saveRef?: { current: (() => void) | null }; onToggleLane(name: string): void; onMoveLane(name: string, to: number): void; @@ -22,7 +24,9 @@ export interface BoardCanvasProps { children?: ReactNode; } -export function BoardCanvas({ vis, byDate, snap, today, store, sprintSel, emptyText, onToggleLane, onMoveLane, onColWidth, saveRef, children }: BoardCanvasProps) { +const NONE = new Set(); + +export function BoardCanvas({ vis, byDate, snap, today, store, sprintSel, emptyText, closedStatuses = NONE, onToggleLane, onMoveLane, onColWidth, saveRef, children }: BoardCanvasProps) { const boardRef = useRef(null); const measureRef = useRef(null); const wrapRef = useRef(null); @@ -33,6 +37,7 @@ export function BoardCanvas({ vis, byDate, snap, today, store, sprintSel, emptyT const [guides, setGuides] = useState<{ x: number | null; y: number | null } | null>(null); const [scroll, setScroll] = useState({ left: 0, wide: false, wrapW: 0, scrollW: 0 }); const [dragTarget, setDragTarget] = useState<{ from: number; to: number } | null>(null); + const closedKeys = useMemo(() => new Set(vis.nodes.filter((n) => isClosed(n.status, closedStatuses)).map((n) => n.key)), [vis.nodes, closedStatuses]); // раскладка как render() links.html: при новой выборке, режиме и ширинах колонок — заново, с сохранённой из хранилища useLayoutEffect(() => { @@ -185,9 +190,9 @@ export function BoardCanvas({ vis, byDate, snap, today, store, sprintSel, emptyT ))} {Object.entries(placed.pos).filter(([k]) => byKey[k]).map(([k, box]) => ( + closed={closedKeys.has(k)} onPointerDown={onCardDown} onAutoSize={onAutoSize} /> ))} - + )} diff --git a/web/src/board/components/Card.test.ts b/web/src/board/components/Card.test.ts index 2945119..e3ea72e 100644 --- a/web/src/board/components/Card.test.ts +++ b/web/src/board/components/Card.test.ts @@ -12,5 +12,6 @@ describe("cardPropsEqual (T11.1)", () => { expect(cardPropsEqual(base, { ...base, box: { x: 9, y: 2, w: 240, h: 120 } })).toBe(false); expect(cardPropsEqual(base, { ...base, node: { key: "A-2" } as CardProps["node"] })).toBe(false); expect(cardPropsEqual(base, { ...base, sprintSel: new Set(["S"]) })).toBe(false); + expect(cardPropsEqual(base, { ...base, closed: true })).toBe(false); }); }); diff --git a/web/src/board/components/Card.tsx b/web/src/board/components/Card.tsx index 0623e40..5c22ddd 100644 --- a/web/src/board/components/Card.tsx +++ b/web/src/board/components/Card.tsx @@ -12,6 +12,7 @@ export interface CardProps { rel?: RelBox; multiSprint: boolean; sprintSel: Set; + closed?: boolean; // задача закрыта (статус из closed_statuses) — карточка приглушена onPointerDown?: (ev: PointerEvent, key: string) => void; onAutoSize?: (key: string) => void; } @@ -19,12 +20,12 @@ export interface CardProps { // T11.1: при перетаскивании одной карточки place() создаёт новые box у всех — сравниваем box по значению export function cardPropsEqual(a: CardProps, b: CardProps): boolean { const sameBox = a.box === b.box || (!!a.box && !!b.box && a.box.x === b.box.x && a.box.y === b.box.y && a.box.w === b.box.w && a.box.h === b.box.h); - return sameBox && a.node === b.node && a.rel === b.rel && a.multiSprint === b.multiSprint && a.sprintSel === b.sprintSel && + return sameBox && a.node === b.node && a.rel === b.rel && a.multiSprint === b.multiSprint && a.sprintSel === b.sprintSel && !!a.closed === !!b.closed && a.onPointerDown === b.onPointerDown && a.onAutoSize === b.onAutoSize; } -export const Card = memo(function Card({ node: n, box, rel, multiSprint, sprintSel, onPointerDown, onAutoSize }: CardProps) { - const cls = "card" + (n.dim || n.stub ? " out" : "") + (n.crit ? " crit" : "") + (n.focus ? " focus" : ""); +export const Card = memo(function Card({ node: n, box, rel, multiSprint, sprintSel, closed, onPointerDown, onAutoSize }: CardProps) { + const cls = "card" + (n.dim || n.stub ? " out" : "") + (closed ? " closed" : "") + (n.crit ? " crit" : "") + (n.focus ? " focus" : ""); const style = box ? { left: box.x, top: box.y, width: box.w, height: box.h } : { width: CW }; const showTag = !n.stub && (multiSprint || n.dim); // метка спринта — если спринтов на доске больше одного или это сосед return ( diff --git a/web/src/board/components/Edges.tsx b/web/src/board/components/Edges.tsx index 5b90427..14e3604 100644 --- a/web/src/board/components/Edges.tsx +++ b/web/src/board/components/Edges.tsx @@ -3,7 +3,7 @@ import { edgePath } from "../layout/edges.ts"; import { RELATED } from "../constants.ts"; import type { VisLink } from "../types.ts"; -export function Edges({ links, placed }: { links: VisLink[]; placed: Placed }) { +export function Edges({ links, placed, closedKeys }: { links: VisLink[]; placed: Placed; closedKeys?: Set }) { return ( @@ -18,7 +18,8 @@ export function Edges({ links, placed }: { links: VisLink[]; placed: Placed }) { const a = placed.pos[l.src], b = placed.pos[l.dst]; if (!a || !b) return null; const related = l.type === RELATED; - return ${l.dst}${l.type}`} d={edgePath(a, b)} fill="none" + const closed = !!closedKeys && (closedKeys.has(l.src) || closedKeys.has(l.dst)); // закрыт хоть один конец — приглушена + return ${l.dst}${l.type}`} d={edgePath(a, b)} fill="none" className={closed ? "closed" : undefined} stroke={related ? "var(--edge)" : l.hand ? "var(--guide)" : "var(--edge-strong)"} strokeWidth={l.crit ? "4" : related ? "1.4" : "1.8"} strokeDasharray={related ? "5 4" : undefined}