feat(web): верхняя панель — разделы, данные Jira, обновление, тема (#65)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-22 00:23:39 +04:00
parent e55a482182
commit 3e2f252aac
20 changed files with 275 additions and 77 deletions

View File

@ -58,7 +58,7 @@ test("ⓘ открывает карточку окном поверх доски
await expect(page).toHaveURL(/\/app\/issues\/AURORA-9001$/);
await expect(page.getByRole("dialog").getByRole("heading", { name: "Схема свода" })).toBeVisible();
await expect(page.getByTestId("page-board")).toBeAttached();
await expect(page.getByRole("link", { name: "Доска", exact: true })).toHaveClass(/active/);
await expect(page.getByRole("link", { name: "Доска связей", exact: true })).toHaveAttribute("aria-current", "page");
});
test("M-3: прокрутка и данные доски после окна карточки и после другого раздела", async ({ page }) => {
@ -85,7 +85,7 @@ test("M-3: прокрутка и данные доски после окна к
// ссылки меню вверху страницы: обычный click прокрутил бы доску к ним до перехода и затёр запомненное, поэтому dispatchEvent
await page.getByRole("link", { name: "Задачи", exact: true }).dispatchEvent("click");
await expect(page).toHaveURL(/\/app\/issues\?project=AWPCA$/);
await page.getByRole("link", { name: "Доска", exact: true }).dispatchEvent("click");
await page.getByRole("link", { name: "Доска связей", exact: true }).dispatchEvent("click");
await expect.poll(() => page.evaluate(() => window.scrollY)).toBeGreaterThan(y - 2);
});

View File

@ -9,7 +9,7 @@ export async function login(page: Page, path = "/app/board") {
await page.goto(path);
await page.getByLabel("Клиентский токен хаба").fill(tokens().client);
await page.getByRole("button", { name: "Войти" }).click();
await expect(page.getByRole("link", { name: "Доска" })).toBeVisible();
await expect(page.getByRole("link", { name: "Доска связей" })).toBeVisible();
}
async function call(path: string, body: unknown) {

View File

@ -27,7 +27,7 @@ describe("оболочка", () => {
expect(router.state.location.pathname).toBe("/app/issues");
expect(router.state.location.search).toBe("?project=AWPCA");
expect(await screen.findByTestId("page-issues")).toBeInTheDocument();
await userEvent.click(screen.getByRole("link", { name: "Доска" }));
await userEvent.click(screen.getByRole("link", { name: "Доска связей" }));
expect(router.state.location.pathname).toBe("/app/board");
expect(router.state.location.search).toBe("?project=AWPCA");
expect(await screen.findByTestId("page-board")).toBeInTheDocument();
@ -42,6 +42,7 @@ describe("оболочка", () => {
it("«Сменить токен» показывает экран входа", async () => {
render(<App router={at("/app/board")} />);
await userEvent.click(await screen.findByRole("button", { name: "Ещё" }));
await userEvent.click(screen.getByRole("button", { name: "Сменить токен" }));
expect(getAuth().token).toBe("");
expect(screen.getByRole("heading", { name: "Вход в хаб" })).toBeInTheDocument();
@ -56,13 +57,13 @@ describe("оболочка", () => {
render(<App router={router} />);
expect(await screen.findByText(/сломалось/)).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Перезагрузить" })).toBeInTheDocument();
expect(screen.getByRole("link", { name: "Доска" })).toBeInTheDocument();
expect(screen.getByRole("link", { name: "Доска связей" })).toBeInTheDocument();
} finally { PAGE_ROUTES.splice(PAGE_ROUTES.indexOf(boom), 1); }
});
it("без токена — только экран входа", () => {
clearToken();
render(<App router={at("/app/board")} />);
expect(screen.queryByRole("link", { name: "Доска" })).not.toBeInTheDocument();
expect(screen.queryByRole("link", { name: "Доска связей" })).not.toBeInTheDocument();
});
});

View File

@ -19,8 +19,9 @@ export const qk = {
export const useSettings = () =>
useQuery({ queryKey: qk.settings, queryFn: () => api.get<HubSettings>("/settings"), staleTime: Infinity });
export const useBoard = (project: string) =>
useQuery({ queryKey: qk.board(project), queryFn: () => api.get<BoardSnapshot>("/board", { project }) });
// project === null — проект ещё не выбран: запроса нет
export const useBoard = (project: string | null) =>
useQuery({ queryKey: qk.board(project ?? ""), enabled: project !== null, queryFn: () => api.get<BoardSnapshot>("/board", { project: project! }) });
export const useProjects = () =>
useQuery({ queryKey: qk.projects, queryFn: () => api.get<{ projects?: Project[] }>("/projects").then((r) => r.projects ?? []) });

View File

@ -1,4 +1,4 @@
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import { fireEvent, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { readFileSync } from "node:fs";
import { join } from "node:path";
@ -111,34 +111,21 @@ describe("страница доски", () => {
expect(await screen.findByText("jira-export.csv (из файла)")).toBeInTheDocument();
});
it("тема переключается по кругу и применяется к документу", async () => {
it("доска не рисует «Обновить из Jira» и ход пакета: они в верхней панели", async () => {
hub();
page();
await screen.findByText(/задач из 9/);
expect(screen.queryByRole("button", { name: "Обновить из Jira" })).not.toBeInTheDocument();
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");
expect(screen.queryByRole("button", { name: /^Тема/ })).not.toBeInTheDocument();
});
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 () => {
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();
expect(screen.queryByRole("status")).not.toBeInTheDocument();
});
it("пустой link_types при заполненном kind_labels — называет только недостающий словарь", async () => {

View File

@ -1,7 +1,4 @@
import { useEffect, useState } from "react";
import { useJiraRefresh } from "../../shell/refresh.ts";
import { refreshText } from "../../shell/refreshStatus.ts";
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";
@ -15,23 +12,17 @@ interface ToolbarProps {
}
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 refresh = useJiraRefresh(); // кнопка и ход пакета временно здесь; в верхней панели — с Task 5
const rt = refresh.event ? refreshText(refresh.event) : null;
const st = p.status ? statusText(p.status) : rt ? { text: rt.text, warn: rt.tone === "error" || rt.tone === "warning" } : null;
const st = p.status ? statusText(p.status) : null; // только события доски; ход пакета «Обновить из Jira» — в верхней панели
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={refresh.busy} onClick={() => void refresh.start()}
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) => {
@ -61,7 +52,6 @@ export function Toolbar(p: ToolbarProps) {
<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">

View File

@ -41,7 +41,7 @@ describe("карточка окном", () => {
expect(await within(dialog()).findByRole("heading", { name: /AURORA-9002/ })).toBeInTheDocument();
expect(within(screen.getByRole("main")).getByRole("table")).toBe(table); // таблица команд карточки — в окне, не в main // фон не пересоздаётся
expect(screen.getByLabelText("Спринт")).toHaveValue("AWPCA_Спринт 2"); // фильтр фона — из его адреса
expect(screen.getByRole("link", { name: "Задачи" })).toHaveClass("active");
expect(screen.getByRole("link", { name: "Задачи" })).toHaveAttribute("aria-current", "page");
await userEvent.click(within(dialog()).getByRole("button", { name: "Закрыть" }));
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
expect(router.state.location.pathname).toBe("/app/issues");
@ -88,7 +88,7 @@ describe("карточка окном", () => {
expect(router.state.location.pathname).toBe("/app/issues/AURORA-9001");
});
it("с доски: окно поверх доски, вкладка «Доска» активна, после закрытия доска та же", async () => {
it("с доски: окно поверх доски, раздел «Доска связей» активен, после закрытия доска та же", async () => {
const router = at(["/app/board"]);
render(<App router={router} />);
const ilink = await screen.findByRole("link", { name: "Открыть карточку AURORA-9001" });
@ -96,7 +96,7 @@ describe("карточка окном", () => {
await userEvent.click(ilink);
expect(await within(dialog()).findByRole("heading", { name: /AURORA-9001/ })).toBeInTheDocument();
expect(screen.getByTestId("page-board")).toBe(boardEl);
expect(screen.getByRole("link", { name: "Доска" })).toHaveClass("active");
expect(screen.getByRole("link", { name: "Доска связей" })).toHaveAttribute("aria-current", "page");
await userEvent.click(within(dialog()).getByRole("button", { name: "Закрыть" }));
await waitFor(() => expect(router.state.location.pathname).toBe("/app/board"));
expect(screen.getByTestId("page-board")).toBe(boardEl);

View File

@ -5,7 +5,7 @@ import type { Project } from "../api/types.ts";
import "./projects.css";
// окно «Новый проект» (как IssueModal): <dialog> держит фокус; Esc и «Отмена» — onClose, ошибка хаба — рядом с «Создать»
export function NewProjectDialog({ onClose, onCreated }: { onClose(): void; onCreated(p: Project): void }) {
export function NewProjectDialog({ onClose, onCreated }: { onClose(): void; onCreated(p: Project, batchId: number): void }) {
const ref = useRef<HTMLDialogElement>(null);
const [label, setLabel] = useState(""), [name, setName] = useState(""), [board, setBoard] = useState("");
const create = useCreateProject();
@ -15,7 +15,7 @@ export function NewProjectDialog({ onClose, onCreated }: { onClose(): void; onCr
e.preventDefault();
const f: { label: string; name: string; board_id?: number } = { label: label.trim(), name: name.trim() };
if (board.trim() !== "") f.board_id = Number(board);
create.mutate(f, { onSuccess: (r) => onCreated(r.project) });
create.mutate(f, { onSuccess: (r) => onCreated(r.project, r.batch_id) });
};
const err = create.error;
const text = err ? (err instanceof ApiError ? err.detail ?? err.message : err.message) : null;

View File

@ -1,3 +1,4 @@
import { Select } from "../ui/index.ts";
import { useProjectContext } from "./ProjectContext.ts";
const NEW = " new"; // служебное значение пункта «Новый проект…»: метка пробелов содержать не может
@ -6,11 +7,11 @@ export function ProjectPicker() {
const { state, projects, select, openNew } = useProjectContext();
const current = state.kind === "ok" ? state.project.label : "";
return (
<select className="project-picker" aria-label="Проект" value={current}
<Select className="project-picker" aria-label="Проект" value={current}
onChange={(e) => { if (e.target.value === NEW) openNew(); else select(e.target.value); }}>
{current === "" && <option value="" disabled>выберите проект</option>}
{projects.map((p) => <option key={p.label} value={p.label}>{p.label} — {p.name}</option>)}
<option value={NEW}>+ Новый проект…</option>
</select>
</Select>
);
}

View File

@ -1,5 +1,5 @@
/* выбор проекта и окно «Новый проект» */
.appbar .project-picker{font:inherit;font-size:14px;height:30px;max-width:280px;border:1px solid var(--line);border-radius:6px;background:var(--card);color:var(--fg);padding:0 8px}
.appbar .project-picker{max-width:280px;min-width:160px}
.project-gate{display:flex;flex-direction:column;gap:12px;align-items:flex-start;margin:24px 0}
.project-dialog{border:1px solid var(--line);border-radius:10px;background:var(--panel);color:var(--fg);padding:16px 20px;min-width:340px}
.project-dialog::backdrop{background:var(--color-overlay)}

View File

@ -96,15 +96,22 @@ describe("проект в приложении", () => {
});
it("создание проекта из формы", async () => {
const h = hub([A], { "POST /projects": () => json(201, { project: S, batch_id: 7 }) });
const h = hub([A], {
"POST /projects": () => json(201, { project: S, batch_id: 7 }),
"GET /batches/7": () => json(200, { id: 7, author: "viewer", status: "queued", remaining_seconds: 5, counts: {}, commands: [{ type: "refresh", status: "queued" }] }),
});
const router = open("/app/board?project=AWPCA");
await screen.findByTestId("page-board");
const listed = h.get("/batches").length;
await userEvent.selectOptions(screen.getByRole("combobox", { name: "Проект" }), "+ Новый проект…");
const dlg = screen.getByRole("dialog", { name: "Новый проект" });
await userEvent.type(within(dlg).getByLabelText("Метка"), "STOCKS_DRRK");
await userEvent.type(within(dlg).getByLabelText("Название"), "Запасы ДРРК");
await userEvent.click(within(dlg).getByRole("button", { name: "Создать" }));
await waitFor(() => expect(router.state.location.search).toBe("?project=STOCKS_DRRK"));
// пакет проекта отслеживается по batch_id (track): верхняя панель показывает ход, список пакетов не запрашивается
expect(await within(screen.getByRole("banner")).findByRole("status")).toHaveTextContent(/пакет #7 в очереди/);
expect(h.get("/batches").length).toBe(listed);
const post = h.calls.find((c) => c.method === "POST" && c.url.pathname === "/projects")!;
expect(post.body).toEqual({ label: "STOCKS_DRRK", name: "Запасы ДРРК", author: "viewer" });
expect(screen.queryByRole("dialog", { name: "Новый проект" })).not.toBeInTheDocument();

View File

@ -0,0 +1,120 @@
import { render, 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 { createMemoryRouter } from "react-router";
import { beforeEach, describe, expect, it } from "vitest";
import { App } from "../App.tsx";
import { getAuth, setToken } from "../auth/token.ts";
import settings from "../board/__fixtures__/settings.json";
import { routes } from "../routes.tsx";
import { json, mockFetch } from "../test/fetchMock.ts";
const SNAP = JSON.parse(readFileSync(join(import.meta.dirname, "..", "board", "__fixtures__", "hub-basic.board.json"), "utf8"));
const A = { label: "AWPCA", name: "АРМ КА", board_id: null, board_type: null, created_at: "", issues_count: 9 };
const WITH_SYNC = { ...SNAP, last_sync: { started_at: "2026-10-14T11:58:00+00:00", finished_at: "2026-10-14T12:00:00+00:00", issues_count: 9 } };
interface Call { method: string; url: URL }
function hub(over: Record<string, () => Response> = {}) {
const calls: Call[] = [];
mockFetch((method, url) => {
calls.push({ method, url });
const k = `${method} ${url.pathname}`;
if (over[k]) return over[k]();
if (k === "GET /projects") return json(200, { projects: [A] });
if (k === "GET /settings") return json(200, settings);
if (k === "GET /board") return json(200, WITH_SYNC);
if (k === "GET /issues") return json(200, { issues: [] });
if (k === "GET /batches") return json(200, { batches: [] });
if (k === "GET /agent") return json(200, { online: true, last_seen_at: "2026-10-14T09:30:00+00:00" });
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: {}, commands: [{ type: "refresh", status: "running" }] });
return json(404, { error: "not_found" });
});
return { count: (m: string, p: string) => calls.filter((c) => c.method === m && c.url.pathname === p).length };
}
const open = (path: string) => {
const router = createMemoryRouter(routes, { initialEntries: [path], basename: "/app" });
render(<App router={router} />);
return router;
};
const bar = () => screen.getByRole("banner");
describe("верхняя панель", () => {
beforeEach(() => { setToken("tok"); localStorage.clear(); });
it("разделы — ссылки «Доска связей» и «Задачи», активная с aria-current", async () => {
hub();
open("/app/issues?project=AWPCA");
await screen.findByRole("link", { name: "Доска связей" });
await waitFor(() => expect(screen.getByRole("link", { name: "Доска связей" })).toHaveAttribute("href", "/app/board?project=AWPCA"));
expect(screen.getByRole("link", { name: "Задачи" })).toHaveAttribute("aria-current", "page");
expect(screen.getByRole("link", { name: "Доска связей" })).not.toHaveAttribute("aria-current");
});
it("«Обновить из Jira» на /issues ставит пакет, недоступна, по done — «пакет #7 выполнен» и GET /issues повторно", async () => {
let done = false;
const h = hub({ "GET /batches/7": () => json(200, { id: 7, author: "viewer", status: done ? "done" : "running", remaining_seconds: 3, counts: {}, commands: [{ type: "refresh", status: done ? "done" : "running" }] }) });
open("/app/issues?project=AWPCA");
await screen.findByTestId("page-issues");
await waitFor(() => expect(h.count("GET", "/issues")).toBeGreaterThan(0));
const issuesBefore = h.count("GET", "/issues");
const btn = within(bar()).getByRole("button", { name: "Обновить из Jira" });
await userEvent.click(btn);
await waitFor(() => expect(btn).toBeDisabled());
expect(btn).toHaveAttribute("aria-busy", "true");
expect(h.count("POST", "/refresh")).toBe(1);
expect(await within(bar()).findByRole("status")).toHaveTextContent(/пакет #7 выполняется/);
done = true;
await waitFor(() => expect(within(bar()).getByRole("status")).toHaveTextContent("пакет #7 выполнен"), { timeout: 6000 });
await waitFor(() => expect(h.count("GET", "/issues")).toBeGreaterThan(issuesBefore));
expect(btn).toBeEnabled();
});
it("агент офлайн — «агент офлайн»; на связи — «агент на связи»", async () => {
hub({ "GET /agent": () => json(200, { online: false, last_seen_at: "2026-10-14T09:30:00+00:00" }) });
open("/app/board?project=AWPCA");
expect(await within(bar()).findByText("агент офлайн")).toBeInTheDocument();
});
it("агент на связи", async () => {
hub();
open("/app/board?project=AWPCA");
expect(await within(bar()).findByText("агент на связи")).toBeInTheDocument();
});
it("«данные на ДД.ММ ЧЧ:ММ» берутся из last_sync снимка", async () => {
hub();
open("/app/issues?project=AWPCA");
expect(await within(bar()).findByText(/^данные на \d\d\.\d\d \d\d:\d\d$/)).toBeInTheDocument();
});
it("полного обновления не было", async () => {
hub({ "GET /board": () => json(200, { ...SNAP, last_sync: null }) });
open("/app/issues?project=AWPCA");
expect(await within(bar()).findByText("полного обновления не было")).toBeInTheDocument();
});
it("меню «Ещё» → «Сменить токен» показывает экран входа", async () => {
hub();
open("/app/board?project=AWPCA");
await userEvent.click(await screen.findByRole("button", { name: "Ещё" }));
await userEvent.click(screen.getByRole("button", { name: "Сменить токен" }));
expect(getAuth().token).toBe("");
expect(screen.getByRole("heading", { name: "Вход в хаб" })).toBeInTheDocument();
});
it("переключатель темы — в панели", async () => {
hub();
open("/app/board?project=AWPCA");
expect(await within(bar()).findByRole("switch", { name: "Тёмная тема" })).toBeInTheDocument();
});
it("доска не рисует свою кнопку «Обновить из Jira»: в приложении она одна", async () => {
hub();
open("/app/board?project=AWPCA");
await screen.findByTestId("page-board");
await screen.findByText(/задач из/);
expect(screen.getAllByRole("button", { name: "Обновить из Jira" })).toHaveLength(1);
});
});

28
web/src/shell/AppBar.tsx Normal file
View File

@ -0,0 +1,28 @@
import { useQueryClient } from "@tanstack/react-query";
import { clearToken } from "../auth/token.ts";
import { withProject } from "../projects/current.ts";
import { ProjectPicker } from "../projects/ProjectPicker.tsx";
import { Menu, MenuItem, Segmented, ThemeSwitch } from "../ui/index.ts";
import { DataStatus } from "./DataStatus.tsx";
export type Section = "/board" | "/issues";
// верхняя панель (design.md, решение 9): название, проект, разделы, состояние данных, тема, меню «Ещё»
export function AppBar({ section, project }: { section: Section; project: string | null }) {
const qc = useQueryClient();
const href = (to: Section) => (project ? to + withProject("", project) : to);
return (
<header className="appbar">
<span className="appbar__brand">Хаб Jira</span>
<ProjectPicker />
<Segmented label="Раздел" value={section}
options={[{ value: "/board", label: "Доска связей", href: href("/board") }, { value: "/issues", label: "Задачи", href: href("/issues") }]} />
<span className="appbar__spacer" />
<DataStatus project={project} />
<ThemeSwitch />
<Menu label="Ещё">
{(close) => <MenuItem onSelect={() => { close(); clearToken(); qc.clear(); }}>Сменить токен</MenuItem>}
</Menu>
</header>
);
}

View File

@ -0,0 +1,31 @@
import { useBoard } from "../api/queries.ts";
import { fmtStamp } from "../board/model/util.ts";
import { Button, IconRefresh } from "../ui/index.ts";
import { useJiraRefresh } from "./refresh.ts";
import { refreshText } from "./refreshStatus.ts";
// состояние данных: агент, время последнего полного обновления, кнопка «Обновить из Jira» и ход пакета
export function DataStatus({ project }: { project: string | null }) {
const refresh = useJiraRefresh();
const board = useBoard(project);
const ls = board.data?.last_sync ?? null;
const agent = refresh.agent;
const rt = refresh.event ? refreshText(refresh.event) : null;
const seen = agent?.last_seen_at ? `последний выход на связь: ${fmtStamp(agent.last_seen_at)}` : "выхода на связь ещё не было";
return (
<div className="data-status">
{agent && (
<span className="data-status__agent" title={seen}>
<span className={"data-status__dot" + (agent.online ? " is-online" : "")} aria-hidden="true" />
{agent.online ? "агент на связи" : "агент офлайн"}
</span>
)}
{board.data && <span className="data-status__sync">{ls ? `данные на ${fmtStamp(ls.finished_at)}` : "полного обновления не было"}</span>}
<Button variant="primary" icon={<IconRefresh />} loading={refresh.busy} onClick={() => void refresh.start()}
title="Поставить в хаб пакет «обновление по базовой JQL» и дождаться его: агент перечитает задачи из Jira">
Обновить из Jira
</Button>
{rt && <span className={`data-status__event data-status__event--${rt.tone}`} role="status">{rt.text}</span>}
</div>
);
}

View File

@ -1,21 +1,19 @@
import { useQueryClient } from "@tanstack/react-query";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Link, matchPath, useLocation, useNavigate, useRoutes } from "react-router";
import { matchPath, useLocation, useNavigate, useRoutes } from "react-router";
import { useProjects } from "../api/queries.ts";
import { clearToken } from "../auth/token.ts";
import { CloseAbortContext } from "../issues/closeAbort.ts";
import { IssueModal } from "../issues/IssueModal.tsx";
import { cardState, LIST_BACKGROUND } from "../issues/modal.ts";
import { rememberedProject, rememberProject, resolveProject, withProject } from "../projects/current.ts";
import { NewProjectDialog } from "../projects/NewProjectDialog.tsx";
import { ProjectContext, type ProjectCtx, type ProjectState } from "../projects/ProjectContext.ts";
import { ProjectPicker } from "../projects/ProjectPicker.tsx";
import { AppBar } from "./AppBar.tsx";
import { ErrorBoundary } from "./ErrorBoundary.tsx";
import { PAGE_ROUTES } from "./pages.tsx";
import { useJiraRefresh } from "./refresh.ts";
import "./shell.css";
export function Layout() {
const qc = useQueryClient();
const location = useLocation();
const navigate = useNavigate();
const issueKey = matchPath("/issues/:key", location.pathname)?.params.key ?? null;
@ -77,25 +75,15 @@ export function Layout() {
const ctx = useMemo<ProjectCtx>(() => ({ state, projects: projects.data ?? [], select, openNew: () => setNewOpen(true) }),
[state, projects.data, select]);
const tab = (to: string, label: string) => {
const active = at === to || at.startsWith(to + "/");
return <Link to={okLabel ? { pathname: to, search: withProject("", okLabel) } : to} className={active ? "active" : undefined}
aria-current={active ? "page" : undefined}>{label}</Link>;
};
const refresh = useJiraRefresh();
return (
<ProjectContext value={ctx}>
<header className="appbar">
<span className="brand">Хаб Jira</span>
<ProjectPicker />
<nav>{tab("/board", "Доска")}{tab("/issues", "Задачи")}</nav>
<span className="spacer" />
<button type="button" className="link" onClick={() => { clearToken(); qc.clear(); }}>Сменить токен</button>
</header>
<main className="page">
<AppBar section={section} project={okLabel} />
<main className={"page" + (section === "/board" ? " page--board" : "")}>
<ErrorBoundary key={at}>{page}</ErrorBoundary>
</main>
{issueKey && <CloseAbortContext value={abortClose}><IssueModal issueKey={issueKey} onClose={close} /></CloseAbortContext>}
{newOpen && <NewProjectDialog onClose={() => setNewOpen(false)} onCreated={(p) => { setNewOpen(false); select(p.label); }} />}
{newOpen && <NewProjectDialog onClose={() => setNewOpen(false)} onCreated={(p, batchId) => { setNewOpen(false); refresh.track(batchId); select(p.label); }} />}
</ProjectContext>
);
}

View File

@ -93,7 +93,9 @@ export function RefreshProvider({ children, pollMs = POLL_MS }: { children: Reac
qc.removeQueries({ queryKey: ["batch", id] });
// перечитывание активных снимков досок — после своего пакета, подхват не нужен; показанный файл не заменяется (M-1)
skipPickup.current = qc.getQueryCache().findAll({ queryKey: qk.boardAll, type: "active" }).length;
for (const key of [qk.boardAll, ["issues"], ["issue"], qk.projects]) void qc.invalidateQueries({ queryKey: key });
// T4.1: перечитывание могло упасть (успеха нет, счётчик не уменьшился) — по его завершению счётчик гасится в любом случае
void qc.invalidateQueries({ queryKey: qk.boardAll }).finally(() => { skipPickup.current = 0; });
for (const key of [["issues"], ["issue"], qk.projects]) void qc.invalidateQueries({ queryKey: key });
}, [qc]);
useEffect(() => {

View File

@ -6,7 +6,7 @@ import { useBoard, useIssues } from "../api/queries.ts";
import { makeQueryClient } from "../api/queryClient.ts";
import { getToken, setToken } from "../auth/token.ts";
import { json } from "../test/fetchMock.ts";
import { batch, deferred, hub, sleep } from "../test/batchHub.ts";
import { batch, deferred, hub, SNAP, sleep } from "../test/batchHub.ts";
import { useJiraRefresh, type JiraRefresh } from "./refresh.ts";
import { RefreshProvider } from "./RefreshProvider.tsx";
@ -147,6 +147,27 @@ describe("RefreshProvider", () => {
expect(getToken()).toBe("new-tok");
});
it("T4.1: упавшее перечитывание снимка после своего пакета не гасит подхват следующей независимой загрузки", async () => {
let boardFails = false, listed: unknown[] = [];
const h = hub({
"GET /board": () => (boardFails ? json(500, { error: "boom" }) : json(200, SNAP)),
"GET /batches": () => json(200, { batches: listed }),
"GET /batches/:id": (url) => (url.pathname.endsWith("/8") ? json(200, { id: 8, author: "viewer", status: "running", remaining_seconds: 3, counts: {}, commands: [{ type: "refresh", status: "running" }] }) : batch("done")),
});
const { result } = renderHook(() => ({ r: useJiraRefresh(), b: useBoard("AWPCA") }), { wrapper: wrap() });
await waitFor(() => expect(result.current.b.data).toBeDefined());
await waitFor(() => expect(h.count("GET /batches")).toBeGreaterThanOrEqual(1));
boardFails = true;
await act(() => result.current.r.start());
await waitFor(() => expect(result.current.r.event).toEqual({ kind: "batch-done", id: 7 }));
await waitFor(() => expect(h.count("GET /board")).toBe(2)); // перечитывание после пакета упало
await sleep(30);
boardFails = false;
listed = [{ id: 8, author: "viewer", status: "running" }];
await act(async () => { await result.current.b.refetch(); });
await waitFor(() => expect(result.current.r.event).toMatchObject({ kind: "batch", id: 8 }));
});
it("по завершении инвалидируются board, issues, issue, projects", async () => {
let polls = 0;
const h = hub({ "GET /batches/:id": () => (++polls < 2 ? batch("running") : batch("done")) });

View File

@ -1,8 +1,28 @@
/* оболочка: верхняя панель разделов */
.appbar{display:flex;align-items:center;gap:16px;padding:10px 16px;border-bottom:1px solid var(--line);background:var(--panel)}
.appbar .brand{font-weight:600}
.appbar nav{display:flex;gap:4px}
.appbar nav a{color:var(--fg);text-decoration:none;padding:6px 10px;border-radius:6px}
.appbar nav a.active{background:var(--accent);color:var(--accent-fg)}
.appbar .spacer{flex:1 1 auto}
/* оболочка: верхняя панель разделов (design.md, решение 9) */
.appbar{
display:flex;align-items:center;flex-wrap:wrap;gap:8px 16px;min-height:64px;padding:8px 24px;box-sizing:border-box;
border-bottom:1px solid var(--color-border-default);background:var(--color-bg-surface);
}
.appbar__brand{font-size:16px;line-height:24px;font-weight:700;color:var(--color-text-primary);white-space:nowrap}
.appbar__spacer{flex:1 1 auto}
.data-status{display:flex;align-items:center;flex-wrap:wrap;gap:4px 12px;font-size:var(--fs-caption);line-height:var(--lh-caption);color:var(--color-text-secondary)}
.data-status__agent{display:inline-flex;align-items:center;gap:6px;white-space:nowrap}
.data-status__dot{width:8px;height:8px;border-radius:50%;background:var(--color-status-error)}
.data-status__dot.is-online{background:var(--color-status-success)}
.data-status__sync{white-space:nowrap}
.data-status__event{font-weight:600}
.data-status__event--info{color:var(--color-text-secondary)}
.data-status__event--success{color:var(--color-status-success)}
.data-status__event--warning{color:var(--color-status-warning)}
.data-status__event--error{color:var(--color-status-error)}
.crash{max-width:640px;margin:48px auto}
@media (max-width:1099px){
.appbar .data-status{order:10;flex:1 0 100%}
.appbar .appbar__spacer{flex:1 1 0}
}
@media (max-width:639px){
.appbar{padding-inline:16px}
.appbar__brand{display:none}
.appbar .ui-seg{flex:1 1 100%;order:5}
.appbar .ui-seg__item{flex:1 1 0;justify-content:center}
}

View File

@ -1,7 +1,6 @@
// тема интерфейса: "" — системная, light, dark; ключ graph-theme, как у прежней доски (links.html, строки 1000–1003)
export const THEMES = ["", "light", "dark"] as const;
export type Theme = (typeof THEMES)[number];
export const THEME_NAMES: Record<Theme, string> = { "": "системная", light: "светлая", dark: "тёмная" };
const KEY = "graph-theme";
export function readTheme(): Theme {

View File

@ -7,7 +7,9 @@ h1{font-size:var(--fs-section);line-height:var(--lh-section);font-weight:700;mar
.spacer{flex:1 1 auto}
.hint{font-size:13px;color:var(--muted)}
.warn{color:var(--p-high)}
.page{padding-inline:16px;padding-block:0 32px}
.page{padding-inline:24px;padding-block:0 32px;max-width:1440px;margin-inline:auto}
.page--board{max-width:none}
@media (max-width:639px){.page{padding-inline:16px}}
.login{max-width:560px;margin:48px auto}.login form{display:flex;gap:8px}.login input{flex:1;font:inherit;height:34px;padding:0 10px;border:1px solid var(--line);border-radius:6px;background:var(--card);color:var(--fg)}.login .err{color:var(--p-crit)}
:focus-visible{outline:2px solid var(--color-focus-ring);outline-offset:2px}
@media (prefers-reduced-motion: reduce){*{transition:none!important;animation:none!important}}