feat(web): страница доски — панель, фильтры, легенда, проблемы, таблица, экспорт (#21)

Заглушка модели в useBoardSource стабильна между рендерами, readTheme не возвращает null.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-14 23:14:13 +04:00
parent 2227b8885a
commit 66bcb7e293
17 changed files with 573 additions and 6 deletions

View File

@ -0,0 +1,135 @@
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { beforeEach, describe, expect, it } from "vitest";
import { setToken } from "../auth/token.ts";
import { json, mockFetch } from "../test/fetchMock.ts";
import { renderWithProviders } from "../test/render.tsx";
import settings from "./__fixtures__/settings.json";
import { BoardPage } from "./BoardPage.tsx";
const fx = (name: string) => readFileSync(join(import.meta.dirname, "__fixtures__", name), "utf8");
const SNAP = JSON.parse(fx("hub-basic.board.json"));
function hub(over: Record<string, () => Response> = {}) {
return mockFetch((method, url) => {
const k = `${method} ${url.pathname}`;
if (over[k]) return over[k]();
if (k === "GET /settings") return json(200, settings);
if (k === "GET /board") return json(200, SNAP);
if (k === "GET /batches") return json(200, { batches: [] });
if (k === "GET /agent") return json(200, { online: true });
if (k === "POST /refresh") return json(201, { id: 7, estimate_seconds: 5, count: 1 });
if (k === "GET /batches/7") return json(200, { id: 7, author: "viewer", status: "running", remaining_seconds: 3, counts: {} });
return json(404, { error: "not_found" });
});
}
const page = () => renderWithProviders(<BoardPage />, { route: "/board", path: "/board" });
describe("страница доски", () => {
beforeEach(() => setToken("tok"));
it("строит доску из хаба: источник, статистика, выбор самого наполненного спринта", async () => {
hub();
page();
expect(await screen.findByText(/^Хаб · данные на/)).toBeInTheDocument();
expect(screen.getByText(/задач из 9/)).toBeInTheDocument();
expect(screen.getByRole("button", { name: /AWPCA_Спринт 2/ })).toBeInTheDocument();
});
it("пустые словари в настройках — сообщение, а не пустая доска", async () => {
hub({ "GET /settings": () => json(200, { kind_labels: {}, link_types: {} }) });
page();
expect(await screen.findByText(/В настройках хаба нет kind_labels и link_types/)).toBeInTheDocument();
expect(document.querySelector(".board")).toBeNull();
});
it("переключатели и легенда сохраняются под прежними ключами", async () => {
hub();
page();
await screen.findByText(/задач из 9/);
const nb = screen.getByRole("button", { name: "+ соседи по связям" });
await userEvent.click(nb);
expect(nb).toHaveAttribute("aria-pressed", "true");
expect(localStorage.getItem("graph-neighbors")).toBe("1");
await userEvent.click(within(document.querySelector(".legend") as HTMLElement).getByRole("button", { name: /разработка/ }));
expect(localStorage.getItem("graph-hidekind")).toBe('["dev"]');
});
it("«Проблемы» открывают ящик, счётчик — сроки, исполнители, статусы", async () => {
hub();
page();
await screen.findByText(/задач из 9/);
await userEvent.click(screen.getByRole("button", { name: /Проблемы/ }));
expect(screen.getByText("Проблемы на доске")).toBeInTheDocument();
expect(localStorage.getItem("graph-prob")).toBe("1");
});
it("экспорт JSON показывает выгрузку arm-ka-links/1 с раскладкой", async () => {
hub();
page();
await screen.findByText(/задач из 9/);
await userEvent.click(screen.getByTitle("Ещё"));
await userEvent.click(screen.getByRole("button", { name: "Экспорт JSON" }));
const text = (screen.getByRole("textbox") as HTMLTextAreaElement).value;
expect(JSON.parse(text)).toMatchObject({ format: "arm-ka-links/1", source: "Хаб" });
expect(JSON.parse(text).nodes.find((n: { key: string }) => n.key === "AURORA-9001").layout).not.toBeNull();
});
it("сброс раскладки — с подтверждением в меню", async () => {
hub();
localStorage.setItem("graph-pos:board", '{"AURORA-9001":{"x":1,"ry":40}}');
page();
await screen.findByText(/задач из 9/);
await userEvent.click(screen.getByTitle("Ещё"));
await userEvent.click(screen.getByRole("button", { name: "Сбросить раскладку" }));
await userEvent.click(screen.getByRole("button", { name: "Да, сбросить" }));
expect(localStorage.getItem("graph-pos:board")).toBeNull();
});
it("загрузка CSV — источник «из файла»", async () => {
hub();
page();
await screen.findByText(/задач из 9/);
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
fireEvent.change(input, { target: { files: [new File([fx("jira-export.csv")], "jira-export.csv", { type: "text/csv" })] } });
expect(await screen.findByText("jira-export.csv (из файла)")).toBeInTheDocument();
});
it("тема переключается по кругу и применяется к документу", async () => {
hub();
page();
await screen.findByText(/задач из 9/);
await userEvent.click(screen.getByTitle("Ещё"));
await userEvent.click(screen.getByRole("button", { name: "Тема: системная" }));
expect(document.documentElement.getAttribute("data-theme")).toBe("light");
expect(localStorage.getItem("graph-theme")).toBe("light");
});
it("«Обновить из Jira» ставит пакет и недоступна, пока он идёт", async () => {
const fetch = hub();
page();
await screen.findByText(/задач из 9/);
const btn = screen.getByRole("button", { name: "Обновить из Jira" });
await userEvent.click(btn);
await waitFor(() => expect(btn).toBeDisabled());
expect(fetch.mock.calls.filter(([u, i]) => u === "/refresh" && i?.method === "POST")).toHaveLength(1);
expect(await screen.findByText(/пакет #7 выполняется/)).toBeInTheDocument();
});
it("заголовок страницы — h1, пока идёт пакет в шапке label и строка состояния", async () => {
hub();
page();
await screen.findByText(/задач из 9/);
expect(screen.getByRole("heading", { level: 1 })).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Обновить из Jira" }));
expect(await screen.findByRole("status")).toHaveTextContent(/пакет #7/);
expect(screen.getByText(/^Хаб · данные на/)).toBeInTheDocument();
});
it("пустой link_types при заполненном kind_labels — называет только недостающий словарь", async () => {
hub({ "GET /settings": () => json(200, { kind_labels: { dev: "разработка" }, link_types: {} }) });
page();
expect(await screen.findByText(/В настройках хаба нет link_types —/)).toBeInTheDocument();
});
});

View File

@ -1,2 +1,90 @@
// временная страница (заменяется в Task 11)
export function BoardPage() { return <div data-testid="page-board" />; }
import { useLayoutEffect, useMemo, useRef, useState } from "react";
import { useSettings } from "../api/queries.ts";
import { computeVisible, emptyReason } from "./analysis/visible.ts";
import { BoardCanvas } from "./components/BoardCanvas.tsx";
import { ExportBox } from "./components/ExportBox.tsx";
import { FilterBar } from "./components/FilterBar.tsx";
import { Legend } from "./components/Legend.tsx";
import { LinkTable } from "./components/LinkTable.tsx";
import { problemCount, ProblemsDrawer } from "./components/ProblemsDrawer.tsx";
import { Notice, Toolbar } from "./components/Toolbar.tsx";
import { dictsFromSettings } from "./dicts.ts";
import { exportFileName, exportJson } from "./exportJson.ts";
import { todayIso } from "./model/util.ts";
import { useBoardPrefs } from "./useBoardPrefs.ts";
import { useBoardSource } from "./useBoardSource.ts";
import "./board.css";
export function BoardPage() {
const settings = useSettings();
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);
const today = todayIso();
const vis = useMemo(() => computeVisible(src.model, prefs.filters, prefs.store.laneOrder, today),
[src.model, prefs.filters, prefs.store.laneOrder, today]);
const [exportText, setExportText] = useState<string | null>(null);
const saveRef = useRef<(() => void) | null>(null);
const headRef = useRef<HTMLDivElement>(null);
// высота закреплённой шапки — ящик «Проблемы» и полоса дат липнут под ней (строки 1016–1019)
useLayoutEffect(() => {
const el = headRef.current;
if (!el) return;
const set = () => document.documentElement.style.setProperty("--head-h", (getComputedStyle(el).position === "sticky" ? el.offsetHeight : 0) + "px");
set();
if (typeof ResizeObserver === "undefined") return;
const ro = new ResizeObserver(set);
ro.observe(el);
return () => ro.disconnect();
});
if (settings.isPending) return <p className="hint" data-testid="page-board">Загрузка настроек хаба…</p>;
if (settings.error) return <p className="warn" data-testid="page-board">Настройки хаба не загрузились: {settings.error.message}</p>;
if (d && d.missing.length) {
return (
<div className="notice" role="alert" data-testid="page-board">
В настройках хаба нет {d.missing.join(" и ")} — доска не может перевести виды работ и типы связей Jira. Задайте
словари в config.example.json и выполните <code>uv run jira-hub init --config …</code> или запишите их через PUT /settings.
</div>
);
}
const onExport = () => {
saveRef.current?.();
const now = new Date().toISOString();
const text = JSON.stringify(exportJson(src.model, vis, prefs.filters, now), null, 2);
setExportText(text);
try { // в обычном браузере файл скачается; если нет — остаются текст и «Копировать»
const a = document.createElement("a");
a.href = URL.createObjectURL(new Blob([text], { type: "application/json" }));
a.download = exportFileName(src.model, now);
document.body.appendChild(a); a.click();
setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 1000);
} catch { /* остаётся текст */ }
};
const onFile = (name: string, text: string) => { const r = src.loadFile(name, text); if (r) prefs.applyJson(r); };
return (
<div data-testid="page-board">
<div className="sticky" ref={headRef}>
<Toolbar label={src.label} title={src.title} status={src.status} refreshing={src.refreshing} byDate={prefs.filters.byDate}
snap={prefs.snap} model={src.model} vis={vis} problemCount={problemCount(vis.problems)} problemsOpen={prefs.problems}
tableOpen={prefs.table} onReload={src.reload} onRefresh={() => void src.refreshFromJira()} onFile={onFile}
onProblems={() => prefs.toggle("problems")} onExport={onExport} onTable={() => prefs.toggle("table")}
onSnap={() => prefs.toggle("snap")} onReset={prefs.resetLayout} />
<FilterBar model={src.model} prefs={prefs} />
<Notice model={src.model} />
<Legend prefs={prefs} />
</div>
{exportText !== null && <ExportBox text={exportText} onClose={() => setExportText(null)} />}
<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)}
onToggleLane={prefs.toggleLane} onMoveLane={(name, to) => prefs.moveLane(vis.lanes.map((l) => l.name), name, to)}
onColWidth={prefs.setColWidth} saveRef={saveRef}>
{prefs.problems && <ProblemsDrawer problems={vis.problems} onClose={() => prefs.toggle("problems")} />}
</BoardCanvas>
{prefs.table && <LinkTable links={vis.links} />}
</div>
);
}

View File

@ -159,3 +159,4 @@ td.k{font-family:var(--font-mono);font-size:12px;white-space:nowrap}
.measure{position:absolute;left:-100000px;top:0;visibility:hidden;pointer-events:none}
.measure .card{position:static;height:auto}
.measure .card .title{-webkit-line-clamp:unset}
.top h1{font-size:15px;font-weight:600;margin:0}

View File

@ -14,13 +14,14 @@ export interface LayoutStore { laneOrder: string[]; folded: Set<string>; colWidt
export interface BoardCanvasProps {
vis: Visible; byDate: boolean; snap: boolean; today: string; store: LayoutStore; sprintSel: Set<string>;
emptyText: string;
saveRef?: { current: (() => void) | null };
onToggleLane(name: string): void;
onMoveLane(name: string, to: number): void;
onColWidth(date: string, w: number | null): void;
children?: ReactNode;
}
export function BoardCanvas({ vis, byDate, snap, today, store, sprintSel, emptyText, onToggleLane, onMoveLane, onColWidth, children }: BoardCanvasProps) {
export function BoardCanvas({ vis, byDate, snap, today, store, sprintSel, emptyText, onToggleLane, onMoveLane, onColWidth, saveRef, children }: BoardCanvasProps) {
const boardRef = useRef<HTMLDivElement>(null);
const measureRef = useRef<HTMLDivElement>(null);
const wrapRef = useRef<HTMLDivElement>(null);
@ -49,6 +50,12 @@ export function BoardCanvas({ vis, byDate, snap, today, store, sprintSel, emptyT
const placed = useMemo<Placed | null>(() => (layout ? place(layout, rel, store.folded) : null), [layout, rel, store.folded]);
const live = useRef({ placed, rel, layout });
useLayoutEffect(() => { live.current = { placed, rel, layout }; });
// экспорт JSON сохраняет текущую раскладку (в том числе автоматическую) перед выгрузкой
useLayoutEffect(() => {
if (!saveRef) return;
saveRef.current = () => { const { rel: r, layout: lay } = live.current; if (lay) saveLayout(r, lay.colBounds, byDate); };
return () => { saveRef.current = null; };
}, [saveRef, byDate]);
// перетаскивание за рамку и растяжка за уголок (строки 753–778): направляющие, сетка, не выше своей дорожки
const onCardDown = useCallback((ev: PointerEvent<HTMLDivElement>, key: string) => {

View File

@ -0,0 +1,23 @@
import { useEffect, useRef, useState, type ReactNode } from "react";
// выпадающий список: кнопка открывает, щелчок вне или Escape закрывает (строки 897–904)
export function Dropdown({ button, className = "", children, onOpen }: {
button: (p: { open: boolean; toggle(): void }) => ReactNode; className?: string;
children: (close: () => void) => ReactNode; onOpen?: () => void;
}) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const away = (e: PointerEvent) => { if (!ref.current?.contains(e.target as Node)) setOpen(false); };
document.addEventListener("pointerdown", away);
return () => document.removeEventListener("pointerdown", away);
}, [open]);
const toggle = () => setOpen((o) => { if (!o) onOpen?.(); return !o; });
return (
<div className={"dd " + className} ref={ref} onKeyDown={(e) => { if (e.key === "Escape") setOpen(false); }}>
{button({ open, toggle })}
{open && <div className={"dd-menu" + (className.includes("more") ? " menu" : "")}>{children(() => setOpen(false))}</div>}
</div>
);
}

View File

@ -0,0 +1,20 @@
import { useState } from "react";
export function ExportBox({ text, onClose }: { text: string; onClose(): void }) {
const [copied, setCopied] = useState(false);
const copy = async (ta: HTMLTextAreaElement | null) => {
try { await navigator.clipboard.writeText(text); setCopied(true); setTimeout(() => setCopied(false), 1500); }
catch { ta?.focus(); ta?.select(); }
};
let ta: HTMLTextAreaElement | null = null;
return (
<div className="export">
<div className="row">
<span className="hint">JSON доски: все задачи, связи, раскладка, дорожки и фильтры. Загружается обратно ссылкой «файл…». Если файл не скачался, скопируйте текст.</span>
<button className="btn" type="button" onClick={() => void copy(ta)}>{copied ? "Скопировано" : "Копировать"}</button>
<button className="btn" type="button" onClick={onClose}>Закрыть</button>
</div>
<textarea readOnly spellCheck={false} value={text} ref={(el) => { ta = el; }} />
</div>
);
}

View File

@ -0,0 +1,27 @@
import { sprintBox, sprintItems, whoBox, whoItems } from "../analysis/facets.ts";
import type { BoardPrefsApi } from "../useBoardPrefs.ts";
import type { BoardModel } from "../types.ts";
import { MultiSelect } from "./MultiSelect.tsx";
const TOGGLES = [
{ name: "neighbors", label: "+ соседи по связям", title: "Добавить к отобранным задачам их соседей по связям из любого спринта, любого исполнителя и вида работы. Соседи рисуются пунктиром и считаются отдельно" },
{ name: "onlyHand", label: "⇄ передачи", title: "Оставить только стрелки, где задачу ждёт другой человек. Карточки не убирает — для этого «скрыть без связей»" },
{ name: "onlyLinked", label: "скрыть без связей", title: "Убрать с доски карточки, у которых не осталось ни одной видимой стрелки" },
{ name: "crit", label: "⚑ критический путь", title: "Самая длинная цепочка блокировок по плановым датам окончания" },
{ name: "byDate", label: "▥ по датам", title: "Разложить карточки по колонкам плановых дат окончания. У этого режима своя расстановка: карточку можно двигать внутри её колонки; обычная расстановка не меняется и вернётся, когда режим выключен" },
] as const;
export function FilterBar({ model, prefs }: { model: BoardModel; prefs: BoardPrefsApi }) {
const f = prefs.filters, sb = sprintBox(model), wb = whoBox(model, f);
return (
<div className="bar">
<span>{sb.kind === "text" ? <span className="file-name">{sb.text}</span>
: sb.kind === "multi" && <MultiSelect items={sprintItems(model, f).items} selected={f.sprints} allLabel="Все спринты" onChange={prefs.setSprints} />}</span>
<span>{wb.kind === "text" ? <span className="file-name">{wb.text}</span>
: wb.kind === "multi" && <MultiSelect items={whoItems(model, f).items} selected={f.who} allLabel="Все исполнители" onChange={prefs.setWho} />}</span>
{TOGGLES.map((t) => (
<button key={t.name} className="btn" type="button" aria-pressed={f[t.name]} title={t.title} onClick={() => prefs.toggle(t.name)}>{t.label}</button>
))}
</div>
);
}

View File

@ -0,0 +1,25 @@
import type { ReactNode } from "react";
import type { BoardPrefsApi } from "../useBoardPrefs.ts";
const KINDS = [["decision", "решение"], ["analytics", "аналитика / актуализация"], ["dev", "разработка"], ["ui", "UI"],
["test", "тестирование"], ["org", "организационная"]] as const;
const EDGES = [["block", "блокирует начало → заблокирована", "ln"], ["rel", "связанные", "ln dash"]] as const;
export function Legend({ prefs }: { prefs: BoardPrefsApi }) {
const { hideKind, hideEdge } = prefs.filters;
const btn = (what: "kind" | "edge", value: string, off: boolean, body: ReactNode) => (
<button key={what + value} type="button" className={"lg" + (off ? " off" : "")} aria-pressed={!off}
title={off ? "Показать снова" : "Скрыть на доске"} onClick={() => prefs.toggleHidden(what, value)}>{body}</button>
);
return (
<div className="legend">
{KINDS.map(([k, label]) => btn("kind", k, hideKind.has(k), <><span className="sw" style={{ background: `var(--k-${k})` }} />{label}</>))}
<span className="sep" />
{EDGES.map(([k, label, ln]) => btn("edge", k, hideEdge.has(k), <><span className={ln} />{label}</>))}
<span><span className="ln hand" />передача другому исполнителю</span>
<span><span className="ln crit" />критический путь</span>
<span><span className="out-sw" />сосед по связям, сам под фильтры не попадает</span>
<span className="hint">щелчок по виду работы или типу связи скрывает их</span>
</div>
);
}

View File

@ -0,0 +1,13 @@
import type { VisLink } from "../types.ts";
export function LinkTable({ links }: { links: VisLink[] }) {
return (
<details open>
<summary>Таблица связей</summary>
<div className="tbl"><table>
<thead><tr><th>Блокирует</th><th>Тип</th><th>Заблокирована</th></tr></thead>
<tbody>{links.map((l) => <tr key={`${l.src}>${l.dst}${l.type}`}><td className="k">{l.src}</td><td>{l.type}</td><td className="k">{l.dst}</td></tr>)}</tbody>
</table></div>
</details>
);
}

View File

@ -0,0 +1,40 @@
import { useState } from "react";
import type { FacetItem } from "../analysis/facets.ts";
import { Dropdown } from "./Dropdown.tsx";
// мультиселект с поиском: пустой выбор = без фильтра (строки 913–938)
export function MultiSelect({ items, selected, allLabel, onChange }: {
items: FacetItem[]; selected: Set<string>; allLabel: string; onChange(next: Set<string>): void;
}) {
const [q, setQ] = useState("");
const sel = items.filter((i) => selected.has(i.value));
const label = !sel.length ? allLabel : sel.length === 1 ? sel[0].label : `${sel[0].label} +${sel.length - 1}`;
const shown = items.filter((i) => !q || i.label.toLowerCase().includes(q.toLowerCase()) || i.value.toLowerCase().includes(q.toLowerCase()));
const flip = (v: string, on: boolean) => { const n = new Set(selected); if (on) n.add(v); else n.delete(v); onChange(n); };
return (
<Dropdown onOpen={() => setQ("")} button={({ open, toggle }) => (
<button type="button" className="btn dd-btn" aria-haspopup="listbox" aria-expanded={open} title={sel.map((i) => i.label).join(", ")} onClick={toggle}>
<span className="lbl">{label}</span><span className="car">▼</span>
</button>
)}>
{() => (
<>
<input type="search" placeholder="Поиск…" aria-label="Поиск" autoFocus value={q} onChange={(e) => setQ(e.target.value)} />
<div className="dd-list" role="listbox">
{!shown.length && <div className="dd-empty">ничего не найдено</div>}
{shown.map((i) => (
<label key={i.value} className={"dd-item" + (i.cls ? " " + i.cls : "")} title={i.title || i.label}>
<input type="checkbox" checked={selected.has(i.value)} onChange={(e) => flip(i.value, e.target.checked)} />
<span>{i.label}</span><span className="n">{i.count}</span>
</label>
))}
</div>
<div className="dd-foot">
<button type="button" className="btn all" onClick={() => onChange(new Set(items.map((i) => i.value)))}>Выбрать все</button>
<button type="button" className="btn clear" onClick={() => onChange(new Set())}>Снять выбор</button>
</div>
</>
)}
</Dropdown>
);
}

View File

@ -0,0 +1,30 @@
import { JIRA_URL } from "../constants.ts";
import type { ProblemItem, Problems } from "../types.ts";
export const problemCount = (p: Problems) => p.dates.length + p.unassigned.length + p.late.length;
const K = ({ k }: { k: string }) => <a className="k" href={`${JIRA_URL}/browse/${k}`} target="_blank" rel="noopener">{k}</a>;
function Section({ title, list }: { title: string; list: ProblemItem[] }) {
if (!list.length) return null;
return (
<>
<h4>{title} ({list.length})</h4>
<ul>{list.map((x, i) => <li key={i}><K k={x.a.key} /> → <K k={x.b.key} /> <span className="why">— {x.text}</span></li>)}</ul>
</>
);
}
export function ProblemsDrawer({ problems, onClose }: { problems: Problems; onClose(): void }) {
return (
<aside className="drawer">
<div className="drawer-head"><b>Проблемы на доске</b><button type="button" className="btn sm" onClick={onClose}>Закрыть</button></div>
<div>
{!problemCount(problems) && <div className="ok">По датам, исполнителям и статусам блокеров на видимой части доски проблем нет.</div>}
<Section title="Срок блокера не раньше срока зависимой задачи" list={problems.dates} />
<Section title="Блокер без исполнителя" list={problems.unassigned} />
<Section title="Блокер не начат, а срок зависимой задачи близко" list={problems.late} />
<Section title="Передачи между исполнителями (точки ожидания)" list={problems.hand} />
</div>
</aside>
);
}

View File

@ -0,0 +1,84 @@
import { useEffect, useState } from "react";
import { applyTheme, readTheme, saveTheme, THEME_NAMES, THEMES, type Theme } from "../../shell/theme.ts";
import { statusText, type BoardStatus } from "../status.ts";
import type { BoardModel, Visible } from "../types.ts";
import { NOT_IN_HUB } from "../constants.ts";
import { Dropdown } from "./Dropdown.tsx";
interface ToolbarProps {
label: string; title: string; status: BoardStatus | null; refreshing: boolean; byDate: boolean; snap: boolean;
model: BoardModel; vis: Visible; problemCount: number; problemsOpen: boolean; tableOpen: boolean;
onReload(): void; onRefresh(): void; onFile(name: string, text: string): void; onProblems(): void;
onExport(): void; onTable(): void; onSnap(): void; onReset(): void;
}
export function Toolbar(p: ToolbarProps) {
const [theme, setTheme] = useState<Theme>(readTheme);
const [confirm, setConfirm] = useState(false);
useEffect(() => { if (!confirm) return; const t = setTimeout(() => setConfirm(false), 8000); return () => clearTimeout(t); }, [confirm]);
const st = p.status ? statusText(p.status) : null;
const m = p.model, v = p.vis, r = v.removed;
const coreShown = v.nodes.filter((n) => !n.dim).length, ctxShown = v.nodes.length - coreShown;
const nextTheme = () => { const t = THEMES[(THEMES.indexOf(theme) + 1) % THEMES.length]; setTheme(t); saveTheme(t); applyTheme(t); };
return (
<div className="top">
<h1>Доска связей</h1>
<span className="src">
<span className="file-name" title={p.title}>{p.label}</span>
<button type="button" className="link" title="Загрузить снимок зеркала хаба заново, без обращения к Jira" onClick={p.onReload}>Перечитать</button>
<button type="button" className="link" disabled={p.refreshing} onClick={p.onRefresh}
title="Поставить в хаб пакет «обновление по базовой JQL» и дождаться его: агент перечитает задачи из Jira">Обновить из Jira</button>
<label className="link" title="CSV из фильтра Jira или JSON, выгруженный этой доской. JSON при работе с хабом переносит только расстановку карточек, дорожки и фильтры; CSV показывает снимок файла до «Перечитать»">
файл…
<input type="file" accept=".csv,.json,text/csv,application/json" hidden onChange={async (e) => {
const f = e.target.files?.[0];
e.target.value = "";
if (f) p.onFile(f.name, await f.text());
}} />
</label>
</span>
{st && <span className={"stats" + (st.warn ? " warn" : "")} role="status">{st.text}</span>}
<span className="stats">
<b>{coreShown}</b> задач из {m.nodes.filter((n) => !n.stub).length}
{ctxShown > 0 && <> · <b>+{ctxShown}</b> соседи по связям</>}
{" · "}<b>{v.links.length}</b> связей из {m.links.length}
{r.loose > 0 && <> · скрыто без связей: {r.loose}</>}
</span>
<span className="spacer" />
<button className="btn" type="button" aria-pressed={p.problemsOpen} onClick={p.onProblems}
title="Сроки блокеров, задачи без исполнителя, передачи между людьми">
Проблемы <span className={"n" + (p.problemCount ? "" : " zero")}>{p.problemCount}</span>
</button>
<Dropdown className="more" button={({ open, toggle }) => (
<button type="button" className="btn more-btn" aria-haspopup="menu" aria-expanded={open} title="Ещё" onClick={toggle}>⋯</button>
)}>
{(close) => (
<>
<button type="button" onClick={() => { close(); p.onExport(); }}>Экспорт JSON</button>
<button type="button" aria-pressed={p.tableOpen} onClick={p.onTable}>Таблица связей</button>
<button type="button" aria-pressed={p.snap} onClick={p.onSnap}>Привязка к сетке</button>
<button type="button" onClick={nextTheme}>Тема: {THEME_NAMES[theme]}</button>
<hr />
{!confirm ? <button type="button" className="danger" onClick={() => setConfirm(true)}>Сбросить раскладку</button> : (
<div className="confirm">
<span>Вернуть автоматическую раскладку {p.byDate ? "режима «по датам»" : "(обычную; раскладка «по датам» останется)"}, порядок и раскрытие дорожек?</span>
<div className="row">
<button type="button" className="btn yes" onClick={() => { p.onReset(); setConfirm(false); close(); }}>Да, сбросить</button>
<button type="button" className="btn no" onClick={() => setConfirm(false)}>Нет</button>
</div>
</div>
)}
</>
)}
</Dropdown>
</div>
);
}
// предупреждения о данных под шапкой (строки 878–880)
export function Notice({ model }: { model: BoardModel }) {
const notes = [...model.problems];
if (model.missing) notes.push(`упомянуты в связях, но в ${model.stubLane === NOT_IN_HUB ? "зеркале хаба" : "файле"} отсутствуют: ${model.missing}`);
if (!notes.length) return null;
return <div className="notice">{notes.map((n) => <span key={n}>{n}</span>)}</div>;
}

View File

@ -37,3 +37,4 @@ export const fmtStamp = (iso: string): string => {
};
export const emptyModel = (text: string): BoardModel =>
({ nodes: [], links: [], sprints: {}, fileName: "", problems: [], hasSprint: false, missing: 0, emptyText: text });
export const todayIso = (): string => new Date().toISOString().slice(0, 10);

View File

@ -0,0 +1,67 @@
import { useCallback, useMemo, useState } from "react";
import { initialSprints, initialWho } from "./analysis/facets.ts";
import type { LayoutStore } from "./components/BoardCanvas.tsx";
import * as st from "./storage.ts";
import type { BoardModel, Filters, JsonImport } from "./types.ts";
export interface BoardPrefsApi {
filters: Filters; snap: boolean; table: boolean; problems: boolean; store: LayoutStore;
toggle(name: st.FlagName | "snap" | "table" | "problems"): void;
setSprints(s: Set<string>): void;
setWho(s: Set<string>): void;
toggleHidden(what: "kind" | "edge", value: string): void;
toggleLane(name: string): void;
moveLane(current: string[], name: string, to: number): void;
setColWidth(date: string, w: number | null): void;
resetLayout(): void;
applyJson(r: JsonImport): void;
}
const readStore = (version: number): LayoutStore =>
({ laneOrder: st.laneOrder(), folded: st.foldedSet(), colWidths: st.colWidths(), version });
const pick = (model: BoardModel, f: Filters) =>
({ model, sprints: initialSprints(model, f, st.rememberedSprints()), who: initialWho(model, f, st.rememberedWho()) });
// настройки доски из хранилища links.html: флаги, скрытые виды и типы, выбор спринтов и исполнителей, раскладка
export function useBoardPrefs(model: BoardModel): BoardPrefsApi {
const [prefs, setPrefs] = useState<st.BoardPrefs>(() => { st.migrateMode(); st.migrateStore(); return st.readPrefs(); });
const [store, setStore] = useState<LayoutStore>(() => readStore(0));
const [sel, setSel] = useState(() => pick(model, prefs.filters));
if (sel.model !== model) setSel(pick(model, prefs.filters)); // новая модель — выбор заново, как fillSprints/fillWho
const bump = useCallback(() => setStore((s) => readStore(s.version + 1)), []);
const filters = useMemo<Filters>(() => ({ ...prefs.filters, sprints: sel.sprints, who: sel.who }), [prefs.filters, sel]);
const toggle = useCallback((name: st.FlagName | "snap" | "table" | "problems") => {
setPrefs((p) => {
if (name === "snap" || name === "table" || name === "problems") { st.writeFlag(name, !p[name]); return { ...p, [name]: !p[name] }; }
st.writeFlag(name, !p.filters[name]);
return { ...p, filters: { ...p.filters, [name]: !p.filters[name] } };
});
}, []);
const setSprints = useCallback((s: Set<string>) => { st.writeSprints(s); setSel((x) => ({ ...x, sprints: new Set(s) })); }, []);
const setWho = useCallback((s: Set<string>) => { st.writeWho(s); setSel((x) => ({ ...x, who: new Set(s) })); }, []);
const toggleHidden = useCallback((what: "kind" | "edge", value: string) => {
setPrefs((p) => {
const key = what === "kind" ? "hideKind" : "hideEdge";
const next = new Set(p.filters[key]);
if (next.has(value)) next.delete(value); else next.add(value);
const filters = { ...p.filters, [key]: next };
st.writeHidden(filters.hideKind, filters.hideEdge);
return { ...p, filters };
});
}, []);
const toggleLane = useCallback((name: string) => { st.toggleLane(name); bump(); }, [bump]);
const moveLane = useCallback((current: string[], name: string, to: number) => { if (st.moveLane(current, name, to)) bump(); }, [bump]);
const setColWidth = useCallback((date: string, w: number | null) => { st.setColWidth(date, w); bump(); }, [bump]);
const resetLayout = useCallback(() => { st.resetLayout(prefs.filters.byDate); bump(); }, [prefs.filters.byDate, bump]);
const applyJson = useCallback((r: JsonImport) => {
const patch = st.applyJsonSettings(r);
setPrefs((p) => ({ ...p, filters: { ...p.filters, ...patch } }));
setSel(pick(model, { ...prefs.filters, ...patch }));
bump();
}, [model, prefs.filters, bump]);
return { filters, snap: prefs.snap, table: prefs.table, problems: prefs.problems, store,
toggle, setSprints, setWho, toggleHidden, toggleLane, moveLane, setColWidth, resetLayout, applyJson };
}

View File

@ -177,9 +177,12 @@ export function useBoardSource(dicts: Dicts | null, { pollMs = POLL_MS }: { poll
}, [dicts, board.data, qc]);
const ls = board.data?.last_sync ?? null;
const model = source.kind === "file" ? source.model : hubModel ?? emptyModel(board.isPending
// заглушка без данных — один и тот же объект между рендерами (от identity модели зависит выбор спринтов на странице)
const pending = board.isPending;
const emptyHub = useMemo(() => emptyModel(pending
? "Загрузка данных хаба…"
: "Нет данных: хаб не отвечает. Запустите хаб (jira-hub serve) и нажмите «Перечитать» или загрузите файл CSV из фильтра Jira.");
: "Нет данных: хаб не отвечает. Запустите хаб (jira-hub serve) и нажмите «Перечитать» или загрузите файл CSV из фильтра Jira."), [pending]);
const model = source.kind === "file" ? source.model : hubModel ?? emptyHub;
const label = source.kind === "file" ? `${source.model.fileName} (из файла)`
: board.data ? (ls ? `Хаб · данные на ${fmtStamp(ls.finished_at)}` : "Хаб · полного обновления не было")
: board.isPending ? "Хаб" : "Хаб недоступен";

View File

@ -8,6 +8,9 @@ describe("тема", () => {
localStorage.setItem("graph-theme", "purple");
expect(readTheme()).toBe("");
});
it("без сохранённого значения readTheme возвращает пустую строку, а не null", () => {
expect(readTheme()).toBe("");
});
it("applyTheme ставит и снимает data-theme, saveTheme пишет ключ", () => {
applyTheme("light");
expect(document.documentElement.getAttribute("data-theme")).toBe("light");

View File

@ -7,7 +7,7 @@ const KEY = "graph-theme";
export function readTheme(): Theme {
let v: string | null = null;
try { v = localStorage.getItem(KEY); } catch { /* закрытое хранилище */ }
return (THEMES as readonly string[]).includes(v ?? "") ? (v as Theme) : "";
return (THEMES as readonly string[]).includes(v ?? "") ? ((v ?? "") as Theme) : "";
}
export function saveTheme(t: Theme): void {