diff --git a/openspec/changes/web-modules/design.md b/openspec/changes/web-modules/design.md index 20efa5d..4e77c81 100644 --- a/openspec/changes/web-modules/design.md +++ b/openspec/changes/web-modules/design.md @@ -140,3 +140,7 @@ type HubModule = { | 36 | 24.10.2026 | Task 4: `--topbar-h` удалён из `shell.css` и из `board.css` (`.drawer` на ширине страницы < 852 px статичен, а строка приложения бывает только на < 640 px) | M-6 #73: переменная мертва | вернуть переменную | | 37 | 24.10.2026 | Task 4: прокручивается середина панели (`.sidebar__scroll`: разделы и блок состояния), низ с темой и меню «Ещё» остаётся вне прокрутки; поправка R-1 решения #69 (меню «Ещё» свёрнутой панели выходит за 64 px, при `overflow` на всей панели обрезалось бы) | M-5 #73: на низком окне панель не должна обрезаться | прокрутка всей панели и обрезанное меню | | 38 | 24.10.2026 | Task 4: сводная точка узкого экрана — `role="img"` с `aria-label` «Состояние модулей: ошибка / идёт обновление / агент на связи / агент офлайн»; «идёт обновление» — нейтральный цвет (токена «info» нет); событие пакета узкого экрана — постоянный `role="status"` под строкой (`data-testid="topbar-events"`) | цвет не единственный признак; T10.1 #94 | вернуть `aria-hidden`-строку | +| 39 | 24.10.2026 | Task 5: `GET /board` на `/` («Обзор» грузит снимок доски для сводки jira, решение 13) — тест `no wiki row without root` блока состояния проверяет только отсутствие `GET /wiki/status` | `open()` теста открывает `/`, а он теперь тянет доску; суть теста — строка вики без корня | тест строже, чем нужно; ослабление скрывает запрос статуса вики без корня | +| 40 | 24.10.2026 | Task 5: общий `agentText(a)` (`shell/activity/agentText.ts`) — текст, подсказка и признак «на связи» агента; им пользуются блок состояния и карточки «Обзора» | текст «агент на связи/офлайн/обновите агента» не должен разойтись между двумя местами | рефакторинг блока состояния; тесты блока его держат | +| 41 | 24.10.2026 | Task 5: `useRecentBatches(project)` опрашивает `GET /batches?project=&limit=10` раз в 15 с (`refetchInterval`) | ход пакетов меняется без участия страницы; инвалидация из активности ядра усложнила бы контракт | лишние запросы раз в 15 с на «Обзоре» | +| 42 | 24.10.2026 | Task 5: поле поиска вики на «Обзоре» не ведёт на пустой запрос (Enter при пустой строке ничего не делает); `q` в «Вики» — только начальное значение поля, выбор страницы убирает `q` из адреса | пустой поиск = страница «Вики» без `q`, для неё есть ссылка «Вики»; `hrefOf` страницы по контракту Task 4 не несёт поиск | пользователь, вставший на `?q=` и открывший страницу, теряет в адресе строку поиска (в поле она остаётся) | diff --git a/web/src/api/queries.ts b/web/src/api/queries.ts index 4f3353f..a957969 100644 --- a/web/src/api/queries.ts +++ b/web/src/api/queries.ts @@ -1,11 +1,12 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { api } from "./client.ts"; -import type { CreatedProject, HubSettings, UpdatedProject, Project } from "./types.ts"; +import type { Batch, CreatedProject, HubSettings, UpdatedProject, Project } from "./types.ts"; // запросы ядра: настройки и проекты; запросы модулей — в web/src/modules/<модуль>/api/queries.ts export const qk = { settings: ["settings"] as const, projects: ["projects"] as const, + recentBatches: (project: string) => ["batches", project, "recent"] as const, }; export const useSettings = () => @@ -33,3 +34,11 @@ export function useUpdateProject() { onSuccess: () => qc.invalidateQueries({ queryKey: qk.projects }), }); } + +// «Последние пакеты проекта» на «Обзоре»: до 10 пакетов проекта; ход пакетов меняется без нашего участия — опрос раз в 15 с +export const RECENT_BATCHES = 10; +export const useRecentBatches = (project: string) => + useQuery({ + queryKey: qk.recentBatches(project), refetchInterval: 15_000, + queryFn: () => api.get<{ batches?: Batch[] }>("/batches", { project, limit: RECENT_BATCHES }).then((r) => r.batches ?? []), + }); diff --git a/web/src/modules/jira/Summary.tsx b/web/src/modules/jira/Summary.tsx new file mode 100644 index 0000000..477bdf0 --- /dev/null +++ b/web/src/modules/jira/Summary.tsx @@ -0,0 +1,36 @@ +import { Link } from "react-router"; +import type { Project } from "../../api/types.ts"; +import { Notice } from "../../ui/index.ts"; +import { useBoard } from "./api/queries.ts"; +import { isClosed, useClosedStatuses } from "./api/closed.ts"; +import "./summary.css"; + +// сегодня по местному времени, ГГГГ-ММ-ДД: срок задачи Jira — дата без времени +const today = (now = new Date()): string => + `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`; + +// сводка jira для «Обзора»: свои задачи проекта (не соседи), из них открытые и просроченные — по снимку доски +export function Summary({ project }: { project: Project }) { + const board = useBoard(project.label); + const closed = useClosedStatuses(); + const q = `?project=${encodeURIComponent(project.label)}`; + const own = board.data?.issues.filter((i) => !i.neighbor); + const open = own?.filter((i) => !isClosed(i.status, closed)); + const t = today(); + const late = open?.filter((i) => !!i.duedate && i.duedate.slice(0, 10) < t).length ?? 0; + return ( +
+ {board.isError ? {board.error.message} + : !own || !open ?

Загрузка…

+ : ( +

+ {own.length} задач · {open.length} открытых · {late} просрочено +

)} + +
+ ); +} diff --git a/web/src/modules/jira/index.tsx b/web/src/modules/jira/index.tsx index 94322b3..2fc7d5d 100644 --- a/web/src/modules/jira/index.tsx +++ b/web/src/modules/jira/index.tsx @@ -7,6 +7,7 @@ import { FeedPage } from "./feed/FeedPage.tsx"; import { useIssueOverlay } from "./issues/overlay.tsx"; import { IssuesPage } from "./issues/IssuesPage.tsx"; import { jiraStatus } from "./status.ts"; +import { Summary } from "./Summary.tsx"; // прежний адрес → новый; keyParam — параметр пути (ключ задачи), который дописывается к новому адресу function LegacyRedirect({ to, keyParam }: { to: string; keyParam?: string }) { @@ -35,6 +36,7 @@ export const jiraModule: HubModule = { { to: "/jira/feed", label: "Лента", icon: , padded: true, keepSearch: true }, ], status: jiraStatus, + Summary, useOverlay: useIssueOverlay, projectFields: [{ name: "board_id", label: "Номер доски Jira", hint: "scrum или kanban определится сам", kind: "int" }], }; diff --git a/web/src/modules/jira/summary.css b/web/src/modules/jira/summary.css new file mode 100644 index 0000000..36e44a8 --- /dev/null +++ b/web/src/modules/jira/summary.css @@ -0,0 +1,5 @@ +.jira-summary{display:flex;flex-direction:column;gap:var(--space-3)} +.jira-summary__counts{margin:0;font-size:var(--fs-lg);line-height:var(--lh-lg)} +.jira-summary__late{color:var(--color-status-error);font-weight:600} +.jira-summary__links{display:flex;flex-wrap:wrap;gap:var(--space-3)} +.jira-summary__links a{color:var(--color-action-primary);font-weight:600} diff --git a/web/src/modules/wiki/Summary.tsx b/web/src/modules/wiki/Summary.tsx new file mode 100644 index 0000000..7e611ea --- /dev/null +++ b/web/src/modules/wiki/Summary.tsx @@ -0,0 +1,31 @@ +import { useState, type FormEvent } from "react"; +import { Link, useNavigate } from "react-router"; +import type { Project } from "../../api/types.ts"; +import { SearchBox } from "./Search.tsx"; + +// сводка вики для «Обзора»: число страниц и поиск (Enter открывает «Вики» с этим поиском); без корня — объяснение и привязка +export function Summary({ project }: { project: Project }) { + const navigate = useNavigate(); + const [q, setQ] = useState(""); + const base = `?project=${encodeURIComponent(project.label)}`; + if (!project.wiki_root) { + return ( +
+

У проекта нет корня вики, поэтому вики не синхронизируется.

+ Привязать корень вики +
+ ); + } + const pages = typeof project.wiki_pages === "number" ? project.wiki_pages : 0; + const submit = (e: FormEvent) => { + e.preventDefault(); + if (q.trim() !== "") void navigate(`/wiki${base}&q=${encodeURIComponent(q.trim())}`); + }; + return ( +
+

{pages} страниц

+
+ Вики +
+ ); +} diff --git a/web/src/modules/wiki/WikiPage.tsx b/web/src/modules/wiki/WikiPage.tsx index 7098890..e08907a 100644 --- a/web/src/modules/wiki/WikiPage.tsx +++ b/web/src/modules/wiki/WikiPage.tsx @@ -25,8 +25,9 @@ export function WikiPage({ project }: { project: string }) { const tree = useWikiTree(project, synced); const sync = useModuleActivity("wiki"); - const [q, setQ] = useState(""); - const [dq, setDq] = useState(""); + // начальная строка поиска — из адреса (?q=…): так на страницу ведёт поиск с «Обзора» + const [q, setQ] = useState(() => params.get("q") ?? ""); + const [dq, setDq] = useState(() => (params.get("q") ?? "").trim()); useEffect(() => { const t = setTimeout(() => setDq(q.trim()), q.trim() === "" ? 0 : Q_DELAY); return () => clearTimeout(t); diff --git a/web/src/modules/wiki/index.tsx b/web/src/modules/wiki/index.tsx index 735108c..36d9a82 100644 --- a/web/src/modules/wiki/index.tsx +++ b/web/src/modules/wiki/index.tsx @@ -2,6 +2,7 @@ import { ProjectGate } from "../../projects/ProjectGate.tsx"; import { IconBook } from "../../ui/index.ts"; import type { HubModule } from "../types.ts"; import { wikiStatus } from "./status.ts"; +import { Summary } from "./Summary.tsx"; import { WikiPage } from "./WikiPage.tsx"; export const wikiModule: HubModule = { @@ -10,5 +11,6 @@ export const wikiModule: HubModule = { pages: [{ path: "/wiki", element: {(p) => } }], nav: [{ to: "/wiki", label: "Вики", icon: , padded: true }], status: wikiStatus, + Summary, projectFields: [{ name: "wiki_root", label: "Корень вики", hint: "DOC-… или ссылка на страницу Eva", kind: "text" }], }; diff --git a/web/src/modules/wiki/wiki.css b/web/src/modules/wiki/wiki.css index e96adc5..4e9c8e8 100644 --- a/web/src/modules/wiki/wiki.css +++ b/web/src/modules/wiki/wiki.css @@ -61,3 +61,6 @@ .wiki-att__error{color:var(--color-status-error)} /* плашка хода синхронизации в боковой панели */ +.wiki-summary{display:flex;flex-direction:column;gap:var(--space-3)} +.wiki-summary__text{margin:0;font-size:var(--fs-lg);line-height:var(--lh-lg)} +.wiki-summary__link{color:var(--color-action-primary);font-weight:600;align-self:flex-start} diff --git a/web/src/shell/ModuleStatusBlock.test.tsx b/web/src/shell/ModuleStatusBlock.test.tsx index 1e3063e..ae9cb59 100644 --- a/web/src/shell/ModuleStatusBlock.test.tsx +++ b/web/src/shell/ModuleStatusBlock.test.tsx @@ -70,13 +70,13 @@ describe("блок состояния модулей", () => { expect(err).toHaveClass("data-status__sync--error"); }); - it("no wiki row without root: одна строка, запросов /wiki/status и /board нет", async () => { + it("no wiki row without root: одна строка, запроса /wiki/status нет", async () => { const paths = hub({ wiki_root: null, wiki_synced_at: null }); open(); await within(bar()).findByRole("button", { name: "Обновить из Jira — все проекты" }); expect(rowNow("wiki")).toBeNull(); expect(within(bar()).queryByRole("button", { name: /^Обновить вики/ })).toBeNull(); - expect(paths.filter((p) => p === "GET /wiki/status" || p === "GET /board")).toEqual([]); + expect(paths.filter((p) => p === "GET /wiki/status")).toEqual([]); }); it("живой регион role=status стоит в строке модуля постоянно, до появления события", async () => { diff --git a/web/src/shell/ModuleStatusBlock.tsx b/web/src/shell/ModuleStatusBlock.tsx index f05558f..02bf685 100644 --- a/web/src/shell/ModuleStatusBlock.tsx +++ b/web/src/shell/ModuleStatusBlock.tsx @@ -5,6 +5,7 @@ import type { HubModule } from "../modules/types.ts"; import { fmtStamp } from "../ui/format.ts"; import { IconButton, IconRefresh } from "../ui/index.ts"; import { useModuleActivity } from "./activity/activity.ts"; +import { agentText } from "./activity/agentText.ts"; import { eventText } from "./activity/events.ts"; type WithStatus = HubModule & Required>; @@ -20,17 +21,17 @@ function ModuleRow({ m, project, variant }: { m: WithStatus; project: Project; v const scope = status.scope === "all" ? "все проекты" : "проект"; const action = label ? `${label} — ${status.scope === "all" ? scope : `${scope} ${project.label}`}` : null; const a = act.agent; - const agentText = a ? (a.warning || a.update_hint ? "обновите агента" : a.online ? "агент на связи" : "агент офлайн") : null; - const agentHint = a ? [a.warning, a.update_hint, a.last_seen_at ? `последний выход на связь: ${fmtStamp(a.last_seen_at)}` : "выхода на связь ещё не было"].filter(Boolean).join(" · ") : undefined; + const av = a ? agentText(a) : null; + const agentLabel = av?.text ?? null, agentHint = av?.hint; const freshText = fresh.error ? `синхронизация не удалась: ${fresh.error}` : fresh.at ? `данные на ${fmtStamp(fresh.at)} · ${scope}` : "обновления ещё не было"; const ev = act.event ? eventText(act.event, label ?? "") : null; - const online = !!a && a.online && !a.warning && !a.update_hint; + const online = !!av?.online; const collapsed = variant === "collapsed"; const eventEl = {ev?.text}; const button = action && ( -