diff --git a/web/e2e/feed.spec.ts b/web/e2e/feed.spec.ts
new file mode 100644
index 0000000..6958e7d
--- /dev/null
+++ b/web/e2e/feed.spec.ts
@@ -0,0 +1,44 @@
+import { expect, test } from "@playwright/test";
+import { HUB_URL } from "../playwright.config.ts";
+import { login, tokens } from "./hub.ts";
+
+// журнал изменений засевается агентским маршрутом, как его шлёт агент v13 (#70); id записей уникальны на прогон
+async function seedHistory() {
+ const run = Date.now();
+ const created = new Date(Date.now() - 10 * 60_000).toISOString();
+ const changes = [
+ { history_id: `e2e-${run}-1`, seq: 0, key: "AURORA-9002", created, author: "Петров Пётр", field_id: "status",
+ field_name: "status", from: "1", from_string: "В работе", to: "2", to_string: "Готово" },
+ { history_id: `e2e-${run}-2`, seq: 0, key: "AURORA-9002", created, author: "Петров Пётр", field_id: "assignee",
+ field_name: "assignee", from: null, from_string: null, to: "x".repeat(200), to_string: "Очень-длинное-имя-без-пробелов-".repeat(8) },
+ ];
+ const r = await fetch(HUB_URL + "/agent/history", { method: "POST", headers: { Authorization: "Bearer " + tokens().agent, "Content-Type": "application/json" }, body: JSON.stringify({ changes }) });
+ expect(r.ok).toBe(true);
+}
+
+test("лента: событие за сегодня, карточка окном поверх ленты", async ({ page }) => {
+ await seedHistory();
+ await login(page, "/app/feed?project=AWPCA");
+ const group = page.getByTestId("feed-group").filter({ has: page.getByRole("link", { name: "AURORA-9002", exact: true }) });
+ await expect(group.getByText("статус «В работе» → «Готово»").first()).toBeVisible();
+ const other = page.getByRole("checkbox", { name: "Прочие поля" });
+ await other.click(); // флажок управляемый: состояние приходит из адреса после навигации
+ await expect(other).toBeChecked();
+ await expect(page).toHaveURL(/kinds=status%2Cdescription%2Cdates%2Ccomment%2Cother|kinds=status,description,dates,comment,other/);
+ await expect(group.getByText(/исполнитель:/).first()).toBeVisible();
+ await group.getByRole("link", { name: "AURORA-9002", exact: true }).click();
+ const d = page.getByRole("dialog");
+ await expect(d.getByRole("heading", { name: "API свода" })).toBeVisible();
+ await page.keyboard.press("Escape");
+ await expect(page.getByRole("dialog")).toHaveCount(0);
+ await expect(page.getByTestId("page-feed")).toBeVisible();
+});
+
+test("лента на 390 px: нет горизонтальной прокрутки при длинных значениях", async ({ page }) => {
+ await seedHistory();
+ await page.setViewportSize({ width: 390, height: 844 });
+ await login(page, "/app/feed?project=AWPCA&kinds=status,other");
+ await expect(page.getByTestId("feed-group").first()).toBeVisible();
+ const overflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth);
+ expect(overflow).toBeLessThanOrEqual(0);
+});
diff --git a/web/src/feed/EventList.tsx b/web/src/feed/EventList.tsx
new file mode 100644
index 0000000..708f3a5
--- /dev/null
+++ b/web/src/feed/EventList.tsx
@@ -0,0 +1,20 @@
+import type { ChangeEvent } from "../api/types.ts";
+import { eventText, eventTime } from "./events.ts";
+import "./feed.css";
+
+// строки событий журнала: время, автор, что изменилось (лента и история в карточке; spec web-app «Лента изменений»)
+export function EventList({ events, now, alwaysDate = false }: { events: ChangeEvent[]; now: Date; alwaysDate?: boolean }) {
+ return (
+
+ {events.map((e, n) => (
+ -
+ {eventTime(e.at, now, alwaysDate)}
+
+ {e.author ?? "—"}{" "}
+ {eventText(e)}
+
+
+ ))}
+
+ );
+}
diff --git a/web/src/feed/FeedPage.test.tsx b/web/src/feed/FeedPage.test.tsx
new file mode 100644
index 0000000..42e5421
--- /dev/null
+++ b/web/src/feed/FeedPage.test.tsx
@@ -0,0 +1,152 @@
+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 type { ChangeEvent } from "../api/types.ts";
+import { setToken } from "../auth/token.ts";
+import settings from "../board/__fixtures__/settings.json";
+import { routes } from "../routes.tsx";
+import { json, mockFetch } from "../test/fetchMock.ts";
+import { issue } from "../test/issueFixtures.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 B = { label: "STOCKS", name: "Запасы", board_id: null, board_type: null, created_at: "", issues_count: 1 };
+const minutesAgo = (m: number) => new Date(Date.now() - m * 60_000).toISOString();
+const ev = (over: Partial): ChangeEvent => ({
+ at: minutesAgo(30), key: "AURORA-9002", summary: "API свода", status: "Готово", kind: "status", field: "status",
+ author: "Петров Пётр", from: "В работе", to: "Готово", from_value: null, to_value: null, ...over,
+});
+
+type Changes = { since?: string; until?: string; truncated?: boolean; changes: ChangeEvent[] } | Response;
+function hub(changes: () => Changes = () => ({ changes: [] })) {
+ const calls: URL[] = [];
+ mockFetch((method, url) => {
+ const k = `${method} ${url.pathname}`;
+ if (k === "GET /changes") {
+ calls.push(url);
+ const r = changes();
+ return r instanceof Response ? r : json(200, { since: "", until: "", truncated: false, ...r });
+ }
+ if (k === "GET /projects") return json(200, { projects: [A, B] });
+ if (k === "GET /settings") return json(200, settings);
+ if (k === "GET /board") return json(200, SNAP);
+ 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: null });
+ if (k.startsWith("GET /issues/")) return json(200, issue(decodeURIComponent(url.pathname.split("/").pop()!)));
+ return json(404, { error: "not_found" });
+ });
+ return calls;
+}
+const open = (path: string) => {
+ const router = createMemoryRouter(routes, { initialEntries: [path], basename: "/app" });
+ render();
+ return router;
+};
+const page = () => screen.findByTestId("page-feed");
+
+describe("лента изменений", () => {
+ beforeEach(() => { setToken("tok"); localStorage.clear(); });
+
+ it("по умолчанию — «Сегодня», виды без «Прочих полей», события сгруппированы по задаче", async () => {
+ const calls = hub(() => ({ changes: [
+ ev({}), ev({ key: "AURORA-9001", summary: "Схема", status: "В работе", kind: "comment", field: "comment", from: null, to: "Готово к проверке" }),
+ ev({ at: minutesAgo(40), kind: "description", field: "description" }),
+ ] }));
+ open("/app/feed?project=AWPCA");
+ await page();
+ const groups = await screen.findAllByTestId("feed-group");
+ expect(groups.map((g) => within(g).getByRole("link").textContent)).toEqual(["AURORA-9002", "AURORA-9001"]);
+ expect(within(groups[0]).getByText("API свода")).toBeInTheDocument();
+ expect(within(groups[0]).getByText(/статус «В работе» → «Готово»/)).toBeInTheDocument();
+ expect(within(groups[0]).getByText("описание изменено")).toBeInTheDocument();
+ expect(within(groups[1]).getByText("комментарий: Готово к проверке")).toBeInTheDocument();
+ const q = calls[0].searchParams;
+ expect(q.get("project")).toBe("AWPCA");
+ expect(q.get("kinds")).toBe("status,description,dates,comment");
+ const midnight = new Date(); midnight.setHours(0, 0, 0, 0);
+ expect(new Date(q.get("since")!).getTime()).toBe(midnight.getTime());
+ expect(q.get("limit")).toBe("2000");
+ expect(screen.getByRole("radio", { name: "Сегодня" })).toHaveAttribute("aria-checked", "true");
+ expect(screen.getByText(/3 события/)).toBeInTheDocument();
+ });
+
+ it("«Что закрылось за два часа»: период и виды в адресе и в запросе", async () => {
+ const calls = hub();
+ const router = open("/app/feed?project=AWPCA");
+ await page();
+ await userEvent.click(screen.getByRole("radio", { name: "2 часа" }));
+ for (const name of ["Описание", "Сроки", "Комментарии"]) await userEvent.click(screen.getByRole("checkbox", { name }));
+ await waitFor(() => expect(router.state.location.search).toBe("?project=AWPCA&period=2h&kinds=status"));
+ expect(router.state.location.pathname).toBe("/app/feed");
+ await waitFor(() => expect(calls.at(-1)!.searchParams.get("kinds")).toBe("status"));
+ const since = new Date(calls.at(-1)!.searchParams.get("since")!).getTime();
+ expect(Math.abs(Date.now() - 2 * 3600_000 - since)).toBeLessThan(60_000);
+ });
+
+ it("карточка из ленты — окно поверх, закрытие возвращает ленту с тем же периодом", async () => {
+ hub(() => ({ changes: [ev({})] }));
+ const router = open("/app/feed?project=AWPCA&period=7d");
+ await page();
+ await userEvent.click(await screen.findByRole("link", { name: "AURORA-9002" }));
+ const d = await screen.findByRole("dialog");
+ expect(await within(d).findByRole("heading", { name: "Тема AURORA-9002" })).toBeInTheDocument();
+ expect(screen.getByTestId("page-feed")).toBeInTheDocument();
+ await userEvent.click(within(d).getAllByRole("button", { name: "Закрыть" })[0]);
+ await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
+ expect(router.state.location.pathname + router.state.location.search).toBe("/app/feed?project=AWPCA&period=7d");
+ });
+
+ it("пустой период, truncated, ошибка с «Повторить»", async () => {
+ let mode: "empty" | "truncated" | "error" = "empty";
+ hub(() => mode === "error" ? json(500, { error: "boom", detail: "сбой" })
+ : mode === "truncated" ? { truncated: true, changes: [ev({})] } : { changes: [] });
+ open("/app/feed?project=AWPCA");
+ expect(await screen.findByText("за период изменений нет")).toBeInTheDocument();
+ mode = "truncated";
+ await userEvent.click(screen.getByRole("radio", { name: "Вчера" }));
+ expect(await screen.findByText("показаны последние 1 событий — выберите период короче")).toBeInTheDocument();
+ mode = "error";
+ await userEvent.click(screen.getByRole("radio", { name: "7 дней" }));
+ const retry = await screen.findByRole("button", { name: "Повторить" });
+ mode = "empty";
+ await userEvent.click(retry);
+ expect(await screen.findByText("за период изменений нет")).toBeInTheDocument();
+ });
+
+ it("все флажки сняты — подсказка, запроса нет", async () => {
+ const calls = hub();
+ open("/app/feed?project=AWPCA&kinds=status");
+ await page();
+ await waitFor(() => expect(calls.length).toBe(1));
+ await userEvent.click(screen.getByRole("checkbox", { name: "Статусы" }));
+ expect(await screen.findByText("выберите хотя бы один вид")).toBeInTheDocument();
+ expect(calls.length).toBe(1);
+ });
+
+ it("мусор в адресе: период по умолчанию, неизвестные виды отброшены", async () => {
+ const calls = hub();
+ open("/app/feed?project=AWPCA&period=foo&kinds=status,foo");
+ await page();
+ await waitFor(() => expect(calls.length).toBeGreaterThan(0));
+ expect(screen.getByRole("radio", { name: "Сегодня" })).toHaveAttribute("aria-checked", "true");
+ expect(calls[0].searchParams.get("kinds")).toBe("status");
+ expect(screen.getByRole("checkbox", { name: "Статусы" })).toBeChecked();
+ expect(screen.getByRole("checkbox", { name: "Сроки" })).not.toBeChecked();
+ });
+
+ it("смена проекта: запрос по новому проекту, период и виды сохраняются", async () => {
+ const calls = hub();
+ const router = open("/app/feed?project=AWPCA&period=2h&kinds=status");
+ await page();
+ await userEvent.selectOptions(screen.getByRole("combobox", { name: "Проект" }), "STOCKS");
+ await waitFor(() => expect(calls.at(-1)!.searchParams.get("project")).toBe("STOCKS"));
+ expect(calls.at(-1)!.searchParams.get("kinds")).toBe("status");
+ expect(router.state.location.pathname).toBe("/app/feed");
+ expect(router.state.location.search).toBe("?project=STOCKS&period=2h&kinds=status");
+ });
+});
diff --git a/web/src/feed/FeedPage.tsx b/web/src/feed/FeedPage.tsx
new file mode 100644
index 0000000..c10b390
--- /dev/null
+++ b/web/src/feed/FeedPage.tsx
@@ -0,0 +1,79 @@
+import { useMemo, useState } from "react";
+import { useSearchParams } from "react-router";
+import { useChanges } from "../api/queries.ts";
+import type { ChangeKind } from "../api/types.ts";
+import { IssueLink } from "../issues/IssueLink.tsx";
+import { Badge, Button, Checkbox, Notice, PageHeader, Panel, Segmented } from "../ui/index.ts";
+import { EventList } from "./EventList.tsx";
+import { groupByIssue, KINDS, kindsParam, parseKinds, parsePeriod, PERIODS, periodRange, type Period } from "./events.ts";
+import "./feed.css";
+
+const pad = (n: number) => String(n).padStart(2, "0");
+const stamp = (d: Date) => `${pad(d.getDate())}.${pad(d.getMonth() + 1)} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
+
+function plural(n: number): string {
+ const m10 = n % 10, m100 = n % 100;
+ if (m10 === 1 && m100 !== 11) return "событие";
+ if (m10 >= 2 && m10 <= 4 && (m100 < 12 || m100 > 14)) return "события";
+ return "событий";
+}
+
+// лента изменений проекта за период (spec web-app «Лента изменений», design web-feed 1–7)
+export function FeedPage({ project }: { project: string }) {
+ const [params, setParams] = useSearchParams();
+ const period = parsePeriod(params.get("period"));
+ const kinds = useMemo(() => parseKinds(params.get("kinds")), [params]);
+ // «сейчас» фиксируется на смену периода: границы запроса стабильны, ключ кэша не меняется на каждой отрисовке
+ const [now, setNow] = useState(() => new Date());
+ const range = useMemo(() => periodRange(period, now), [period, now]);
+ const changes = useChanges({ project, since: range.since.toISOString(), until: range.until.toISOString(), kinds: kinds.join(",") },
+ kinds.length > 0);
+
+ const setParam = (name: string, value: string | null) => setParams((p) => {
+ const n = new URLSearchParams(p);
+ if (value) n.set(name, value); else n.delete(name);
+ return n;
+ });
+ const choosePeriod = (p: Period) => { setNow(new Date()); setParam("period", p === "today" ? null : p); };
+ const toggleKind = (k: ChangeKind, on: boolean) => {
+ const next = on ? [...kinds, k] : kinds.filter((x) => x !== k);
+ setParam("kinds", next.length ? kindsParam(next) : "none"); // пустой набор — явное «none», иначе адрес вернул бы умолчание
+ };
+
+ const data = changes.data;
+ const groups = useMemo(() => groupByIssue(data?.changes ?? []), [data]);
+ const count = data?.changes.length ?? 0;
+ const subtitle = kinds.length && data ? `${count} ${plural(count)} · ${stamp(range.since)} — ${stamp(range.until)}` : undefined;
+
+ return (
+
+
+
+
+
+ {KINDS.map((k) => (
+ toggleKind(k.value, e.currentTarget.checked)} />
+ ))}
+
+
+ {!kinds.length &&
выберите хотя бы один вид}
+ {kinds.length > 0 && changes.isError && (
+
void changes.refetch()}>Повторить}>{(changes.error as Error).message}
+ )}
+ {kinds.length > 0 && changes.isPending &&
Загрузка…
}
+ {data && !count &&
за период изменений нет}
+ {groups.map((g) => (
+
+
+
+ {g.summary ?? "нет в зеркале"}
+ {g.status && {g.status}}
+
+
+
+ ))}
+ {data?.truncated &&
{`показаны последние ${count} событий — выберите период короче`}}
+
+ );
+}
diff --git a/web/src/feed/feed.css b/web/src/feed/feed.css
new file mode 100644
index 0000000..9f948e4
--- /dev/null
+++ b/web/src/feed/feed.css
@@ -0,0 +1,16 @@
+/* лента изменений (spec web-app «Лента изменений»): только токены дизайн-системы */
+.feed{display:flex;flex-direction:column;gap:var(--space-4);padding-top:var(--space-6)}
+.feed .ui-page-header{margin-bottom:0}
+.feed__controls{display:flex;flex-wrap:wrap;align-items:center;gap:var(--space-3) var(--space-5)}
+.feed__kinds{display:flex;flex-wrap:wrap;align-items:center;gap:var(--space-2) var(--space-4)}
+.feed__hint{margin:0;color:var(--color-text-secondary)}
+.feed-group{display:flex;flex-direction:column;gap:var(--space-3);min-width:0}
+.feed-group__head{display:flex;flex-wrap:wrap;align-items:center;gap:var(--space-2)}
+.feed-group__key{font-family:var(--font-mono);font-size:13px;color:var(--color-action-primary);text-decoration:none}
+.feed-group__key:hover{text-decoration:underline}
+.feed-group__sum{font-weight:600;min-width:0;overflow-wrap:anywhere}
+.feed-events{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:var(--space-2)}
+.feed-event{display:flex;gap:var(--space-3);font-size:var(--fs-body);line-height:var(--lh-body);min-width:0}
+.feed-event__time{flex:0 0 auto;font-family:var(--font-mono);font-size:13px;color:var(--color-text-secondary);white-space:nowrap}
+.feed-event__body{min-width:0;overflow-wrap:anywhere;color:var(--color-text-primary)}
+.feed-event__author{font-weight:600}
diff --git a/web/src/shell/Layout.tsx b/web/src/shell/Layout.tsx
index 0468854..d262f35 100644
--- a/web/src/shell/Layout.tsx
+++ b/web/src/shell/Layout.tsx
@@ -70,8 +70,11 @@ export function Layout() {
}, [okLabel, fromUrl, issueKey, onPage, at, pageLocation.search, navigate]);
const [newOpen, setNewOpen] = useState(false);
- const section = at === "/issues" || at === "/board" ? at : null;
- const select = useCallback((label: string) => { void navigate({ pathname: section ?? "/board", search: withProject("", label) }); }, [navigate, section]);
+ const section = at === "/issues" || at === "/board" || at === "/feed" ? at : null;
+ // смена проекта: фильтры доски и списка — проектные и сбрасываются; период и виды ленты от проекта не зависят (R-1)
+ const select = useCallback((label: string) => {
+ void navigate({ pathname: section ?? "/board", search: withProject(section === "/feed" ? pageLocation.search : "", label) });
+ }, [navigate, section, pageLocation.search]);
const ctx = useMemo(() => ({ state, projects: projects.data ?? [], select, openNew: () => setNewOpen(true) }),
[state, projects.data, select]);
@@ -80,7 +83,7 @@ export function Layout() {
-
+
{page}
diff --git a/web/src/shell/Sidebar.test.tsx b/web/src/shell/Sidebar.test.tsx
index 8c5abed..b7cac14 100644
--- a/web/src/shell/Sidebar.test.tsx
+++ b/web/src/shell/Sidebar.test.tsx
@@ -52,6 +52,16 @@ describe("боковая панель", () => {
expect(screen.getByRole("link", { name: "Доска связей" })).not.toHaveAttribute("aria-current");
});
+ it("раздел «Лента» ведёт на /feed с проектом и отмечается как текущий", async () => {
+ hub({ "GET /changes": () => json(200, { since: "", until: "", truncated: false, changes: [] }) });
+ open("/app/feed?project=AWPCA");
+ expect(await screen.findByTestId("page-feed")).toBeInTheDocument();
+ const nav = within(bar()).getByRole("navigation", { name: "Разделы" });
+ await waitFor(() => expect(within(nav).getByRole("link", { name: "Лента" })).toHaveAttribute("href", "/app/feed?project=AWPCA"));
+ expect(within(nav).getByRole("link", { name: "Лента" })).toHaveAttribute("aria-current", "page");
+ expect(within(nav).getByRole("link", { name: "Задачи" })).not.toHaveAttribute("aria-current");
+ });
+
it("на странице «не найдено» ни один раздел не активен; ссылки в сообщении ведут на разделы", async () => {
hub();
open("/app/nowhere?project=AWPCA");
diff --git a/web/src/shell/Sidebar.tsx b/web/src/shell/Sidebar.tsx
index b72139a..9f4f600 100644
--- a/web/src/shell/Sidebar.tsx
+++ b/web/src/shell/Sidebar.tsx
@@ -4,17 +4,18 @@ import { Link } from "react-router";
import { clearToken } from "../auth/token.ts";
import { withProject } from "../projects/current.ts";
import { ProjectPicker } from "../projects/ProjectPicker.tsx";
-import { IconBoard, IconButton, IconClose, IconList, IconMenu, IconSidebar, Menu, MenuItem, ThemeSwitch } from "../ui/index.ts";
+import { IconBoard, IconButton, IconClose, IconHistory, IconList, IconMenu, IconSidebar, Menu, MenuItem, ThemeSwitch } from "../ui/index.ts";
import { DataStatus } from "./DataStatus.tsx";
import { useJiraRefresh } from "./refresh.ts";
import { refreshText } from "./refreshStatus.ts";
import { readSidebar, saveSidebar } from "./sidebarPref.ts";
-export type Section = "/board" | "/issues";
+export type Section = "/board" | "/issues" | "/feed";
const SECTIONS: { to: Section; label: string; icon: React.ReactNode }[] = [
{ to: "/board", label: "Доска связей", icon: },
{ to: "/issues", label: "Задачи", icon: },
+ { to: "/feed", label: "Лента", icon: },
];
// узкий экран (<640 px): панель не в потоке, а выезжает по кнопке «Меню» из закреплённой строки (design 4)
diff --git a/web/src/shell/pages.tsx b/web/src/shell/pages.tsx
index fda044e..3ce1720 100644
--- a/web/src/shell/pages.tsx
+++ b/web/src/shell/pages.tsx
@@ -1,5 +1,6 @@
import { Navigate, type RouteObject } from "react-router";
import { BoardPage } from "../board/BoardPage.tsx";
+import { FeedPage } from "../feed/FeedPage.tsx";
import { IssuesPage } from "../issues/IssuesPage.tsx";
import { ProjectGate } from "../projects/ProjectGate.tsx";
import { NotFound } from "./NotFound.tsx";
@@ -9,5 +10,6 @@ export const PAGE_ROUTES: RouteObject[] = [
{ path: "/", element: },
{ path: "/board", element: {(p) => } },
{ path: "/issues", element: {(p) => } },
+ { path: "/feed", element: {(p) => } },
{ path: "*", element: },
];
diff --git a/web/src/ui/icons.tsx b/web/src/ui/icons.tsx
index 9471e52..252d6b9 100644
--- a/web/src/ui/icons.tsx
+++ b/web/src/ui/icons.tsx
@@ -35,4 +35,5 @@ export const IconOpen = make(<>>)
export const IconBoard = make(<>>);
export const IconList = make(<>>);
export const IconMenu = make(<>>);
+export const IconHistory = make(<>>);
export const IconSidebar = make(<>>);