Доска: закрытые задачи и их связи приглушены (#57)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-17 11:46:22 +04:00
parent c5e33a9cc4
commit 80cff5bcfb
7 changed files with 57 additions and 9 deletions

View File

@ -1,4 +1,5 @@
import { useCallback, useMemo, useRef, useState } from "react"; import { useCallback, useMemo, useRef, useState } from "react";
import { useClosedStatuses } from "../api/closed.ts";
import { useSettings } from "../api/queries.ts"; import { useSettings } from "../api/queries.ts";
import { computeVisible, emptyReason } from "./analysis/visible.ts"; import { computeVisible, emptyReason } from "./analysis/visible.ts";
import { BoardCanvas } from "./components/BoardCanvas.tsx"; import { BoardCanvas } from "./components/BoardCanvas.tsx";
@ -17,6 +18,7 @@ import "./board.css";
export function BoardPage() { export function BoardPage() {
const settings = useSettings(); const settings = useSettings();
const closedStatuses = useClosedStatuses();
const d = useMemo(() => (settings.data ? dictsFromSettings(settings.data) : null), [settings.data]); const d = useMemo(() => (settings.data ? dictsFromSettings(settings.data) : null), [settings.data]);
const src = useBoardSource(d && !d.missing.length ? d.dicts : null); const src = useBoardSource(d && !d.missing.length ? d.dicts : null);
const prefs = useBoardPrefs(src.model); const prefs = useBoardPrefs(src.model);
@ -76,7 +78,7 @@ export function BoardPage() {
</div> </div>
{exportText !== null && <ExportBox text={exportText} onClose={() => setExportText(null)} />} {exportText !== null && <ExportBox text={exportText} onClose={() => setExportText(null)} />}
<BoardCanvas vis={vis} byDate={prefs.filters.byDate} snap={prefs.snap} today={today} store={prefs.store} <BoardCanvas vis={vis} byDate={prefs.filters.byDate} snap={prefs.snap} today={today} store={prefs.store}
sprintSel={prefs.filters.sprints} emptyText={vis.nodes.length ? "" : emptyReason(src.model, vis, prefs.filters)} sprintSel={prefs.filters.sprints} emptyText={vis.nodes.length ? "" : emptyReason(src.model, vis, prefs.filters)} closedStatuses={closedStatuses}
onToggleLane={prefs.toggleLane} onMoveLane={(name, to) => prefs.moveLane(vis.lanes.map((l) => l.name), name, to)} onToggleLane={prefs.toggleLane} onMoveLane={(name, to) => prefs.moveLane(vis.lanes.map((l) => l.name), name, to)}
onColWidth={prefs.setColWidth} saveRef={saveRef}> onColWidth={prefs.setColWidth} saveRef={saveRef}>
{prefs.problems && <ProblemsDrawer problems={vis.problems} onClose={() => prefs.toggle("problems")} />} {prefs.problems && <ProblemsDrawer problems={vis.problems} onClose={() => prefs.toggle("problems")} />}

View File

@ -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{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:hover{border-color:var(--accent)}
.card.out{border-style:dashed;opacity:.7} .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.crit{box-shadow:0 0 0 2px var(--guide)}
.card.focus{box-shadow:0 0 0 2px var(--accent)} .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} .card.drag{cursor:grabbing;border-color:var(--accent);box-shadow:0 6px 18px rgba(0,0,0,.18);z-index:2}

View File

@ -32,6 +32,41 @@ function draw(over: Partial<Parameters<typeof BoardCanvas>[0]> = {}) {
const board = () => document.querySelector(".board") as HTMLElement; const board = () => document.querySelector(".board") as HTMLElement;
describe("BoardCanvas", () => { describe("BoardCanvas", () => {
// закрытые (hide-closed): src первой видимой связи — «Готово», её dst — «Снято» (плюс закрытые из фикстуры)
function closedVis() {
const vis = baseProps().vis;
const l = vis.links[0];
const st: Record<string, string> = { [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("дорожки по эпикам и карточки задач с заглушкой", () => { it("дорожки по эпикам и карточки задач с заглушкой", () => {
draw(); draw();
const lanes = [...board().querySelectorAll(".lane-name")].map((e) => e.textContent); const lanes = [...board().querySelectorAll(".lane-name")].map((e) => e.textContent);

View File

@ -1,4 +1,5 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type PointerEvent, type ReactNode } from "react"; 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 { CW, GRID, MINH, MINW, TOP } from "../constants.ts";
import { alignEdges } from "../layout/align.ts"; import { alignEdges } from "../layout/align.ts";
import { clampToCol, computeLayout, measuredHeight, place, type Layout, type Placed, type RelBox } from "../layout/layout.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<string>; colWidt
export interface BoardCanvasProps { export interface BoardCanvasProps {
vis: Visible; byDate: boolean; snap: boolean; today: string; store: LayoutStore; sprintSel: Set<string>; vis: Visible; byDate: boolean; snap: boolean; today: string; store: LayoutStore; sprintSel: Set<string>;
emptyText: string; emptyText: string;
closedStatuses?: Set<string>; // статусы Jira закрытых задач: их карточки и стрелки приглушены (hide-closed), на модель не влияют
saveRef?: { current: (() => void) | null }; saveRef?: { current: (() => void) | null };
onToggleLane(name: string): void; onToggleLane(name: string): void;
onMoveLane(name: string, to: number): void; onMoveLane(name: string, to: number): void;
@ -22,7 +24,9 @@ export interface BoardCanvasProps {
children?: ReactNode; children?: ReactNode;
} }
export function BoardCanvas({ vis, byDate, snap, today, store, sprintSel, emptyText, onToggleLane, onMoveLane, onColWidth, saveRef, children }: BoardCanvasProps) { const NONE = new Set<string>();
export function BoardCanvas({ vis, byDate, snap, today, store, sprintSel, emptyText, closedStatuses = NONE, onToggleLane, onMoveLane, onColWidth, saveRef, children }: BoardCanvasProps) {
const boardRef = useRef<HTMLDivElement>(null); const boardRef = useRef<HTMLDivElement>(null);
const measureRef = useRef<HTMLDivElement>(null); const measureRef = useRef<HTMLDivElement>(null);
const wrapRef = useRef<HTMLDivElement>(null); const wrapRef = useRef<HTMLDivElement>(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 [guides, setGuides] = useState<{ x: number | null; y: number | null } | null>(null);
const [scroll, setScroll] = useState({ left: 0, wide: false, wrapW: 0, scrollW: 0 }); const [scroll, setScroll] = useState({ left: 0, wide: false, wrapW: 0, scrollW: 0 });
const [dragTarget, setDragTarget] = useState<{ from: number; to: number } | null>(null); 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: при новой выборке, режиме и ширинах колонок — заново, с сохранённой из хранилища // раскладка как render() links.html: при новой выборке, режиме и ширинах колонок — заново, с сохранённой из хранилища
useLayoutEffect(() => { 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]) => ( {Object.entries(placed.pos).filter(([k]) => byKey[k]).map(([k, box]) => (
<Card key={k} node={byKey[k]} box={box} rel={rel[k]} multiSprint={multiSprint} sprintSel={sprintSel} <Card key={k} node={byKey[k]} box={box} rel={rel[k]} multiSprint={multiSprint} sprintSel={sprintSel}
onPointerDown={onCardDown} onAutoSize={onAutoSize} /> closed={closedKeys.has(k)} onPointerDown={onCardDown} onAutoSize={onAutoSize} />
))} ))}
<Edges links={vis.links} placed={placed} /> <Edges links={vis.links} placed={placed} closedKeys={closedKeys} />
<Guides x={guides?.x ?? null} y={guides?.y ?? null} width={placed.width} height={placed.height} /> <Guides x={guides?.x ?? null} y={guides?.y ?? null} width={placed.width} height={placed.height} />
</> </>
)} )}

View File

@ -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, 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, node: { key: "A-2" } as CardProps["node"] })).toBe(false);
expect(cardPropsEqual(base, { ...base, sprintSel: new Set(["S"]) })).toBe(false); expect(cardPropsEqual(base, { ...base, sprintSel: new Set(["S"]) })).toBe(false);
expect(cardPropsEqual(base, { ...base, closed: true })).toBe(false);
}); });
}); });

View File

@ -12,6 +12,7 @@ export interface CardProps {
rel?: RelBox; rel?: RelBox;
multiSprint: boolean; multiSprint: boolean;
sprintSel: Set<string>; sprintSel: Set<string>;
closed?: boolean; // задача закрыта (статус из closed_statuses) — карточка приглушена
onPointerDown?: (ev: PointerEvent<HTMLDivElement>, key: string) => void; onPointerDown?: (ev: PointerEvent<HTMLDivElement>, key: string) => void;
onAutoSize?: (key: string) => void; onAutoSize?: (key: string) => void;
} }
@ -19,12 +20,12 @@ export interface CardProps {
// T11.1: при перетаскивании одной карточки place() создаёт новые box у всех — сравниваем box по значению // T11.1: при перетаскивании одной карточки place() создаёт новые box у всех — сравниваем box по значению
export function cardPropsEqual(a: CardProps, b: CardProps): boolean { 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); 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; a.onPointerDown === b.onPointerDown && a.onAutoSize === b.onAutoSize;
} }
export const Card = memo(function Card({ node: n, box, rel, multiSprint, sprintSel, onPointerDown, onAutoSize }: CardProps) { export const Card = memo(function Card({ node: n, box, rel, multiSprint, sprintSel, closed, onPointerDown, onAutoSize }: CardProps) {
const cls = "card" + (n.dim || n.stub ? " out" : "") + (n.crit ? " crit" : "") + (n.focus ? " focus" : ""); 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 style = box ? { left: box.x, top: box.y, width: box.w, height: box.h } : { width: CW };
const showTag = !n.stub && (multiSprint || n.dim); // метка спринта — если спринтов на доске больше одного или это сосед const showTag = !n.stub && (multiSprint || n.dim); // метка спринта — если спринтов на доске больше одного или это сосед
return ( return (

View File

@ -3,7 +3,7 @@ import { edgePath } from "../layout/edges.ts";
import { RELATED } from "../constants.ts"; import { RELATED } from "../constants.ts";
import type { VisLink } from "../types.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<string> }) {
return ( return (
<svg className="edges"> <svg className="edges">
<defs> <defs>
@ -18,7 +18,8 @@ export function Edges({ links, placed }: { links: VisLink[]; placed: Placed }) {
const a = placed.pos[l.src], b = placed.pos[l.dst]; const a = placed.pos[l.src], b = placed.pos[l.dst];
if (!a || !b) return null; if (!a || !b) return null;
const related = l.type === RELATED; const related = l.type === RELATED;
return <path key={`${l.src}>${l.dst}${l.type}`} d={edgePath(a, b)} fill="none" const closed = !!closedKeys && (closedKeys.has(l.src) || closedKeys.has(l.dst)); // закрыт хоть один конец — приглушена
return <path key={`${l.src}>${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)"} stroke={related ? "var(--edge)" : l.hand ? "var(--guide)" : "var(--edge-strong)"}
strokeWidth={l.crit ? "4" : related ? "1.4" : "1.8"} strokeWidth={l.crit ? "4" : related ? "1.4" : "1.8"}
strokeDasharray={related ? "5 4" : undefined} strokeDasharray={related ? "5 4" : undefined}