From 47a156733fb5b04dd0d5d1ad67c31424a13352c8 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 22 Oct 2026 11:17:08 +0400 Subject: [PATCH 1/6] =?UTF-8?q?=D0=A1=D0=BF=D0=B5=D0=BA=D0=B0=20=D0=B8=20?= =?UTF-8?q?=D0=BF=D0=BB=D0=B0=D0=BD:=20=D0=BB=D0=B5=D0=BD=D1=82=D0=B0=20?= =?UTF-8?q?=D0=B8=D0=B7=D0=BC=D0=B5=D0=BD=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=B2?= =?UTF-8?q?=20=D0=B2=D0=B5=D0=B1-=D0=BF=D1=80=D0=B8=D0=BB=D0=BE=D0=B6?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B8=20(#77)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/superpowers/plans/2026-10-22-web-feed.md | 87 +++++++++++++++++ openspec/changes/web-feed/.openspec.yaml | 2 + openspec/changes/web-feed/design.md | 27 ++++++ openspec/changes/web-feed/proposal.md | 25 +++++ .../changes/web-feed/specs/web-app/spec.md | 93 +++++++++++++++++++ 5 files changed, 234 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-22-web-feed.md create mode 100644 openspec/changes/web-feed/.openspec.yaml create mode 100644 openspec/changes/web-feed/design.md create mode 100644 openspec/changes/web-feed/proposal.md create mode 100644 openspec/changes/web-feed/specs/web-app/spec.md diff --git a/docs/superpowers/plans/2026-10-22-web-feed.md b/docs/superpowers/plans/2026-10-22-web-feed.md new file mode 100644 index 0000000..1bb30a2 --- /dev/null +++ b/docs/superpowers/plans/2026-10-22-web-feed.md @@ -0,0 +1,87 @@ +# Лента изменений в веб-приложении — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** страница «Лента» (`/app/feed`) и блок «История» в карточке задачи поверх готовых `GET /changes` и +`GET /issues/{key}.changes`. + +**Architecture:** модуль `web/src/feed/`: чистые функции `events.ts` (период, виды из адреса, текст события, +группировка), компонент `EventList.tsx` (общий для ленты и карточки), страница `FeedPage.tsx`. Маршрут — `shell/pages.tsx`, +раздел — `shell/Sidebar.tsx`, запрос — `api/queries.ts` (`useChanges`, ключ `["changes", …]`), перечитывание после +«Обновить из Jira» — `shell/RefreshProvider.tsx`. + +**Tech Stack:** React 19 + TypeScript, TanStack Query, react-router, Vitest + Testing Library, Playwright. + +**Spec:** `openspec/changes/web-feed/` (proposal, design 1–7, `specs/web-app/spec.md`: MODIFIED «Оболочка и адреса», +ADDED «Лента изменений», «История в карточке задачи»). Контракт хаба — `openspec/specs/hub-api/spec.md` «Лента +изменений», «История задачи в карточке». + +**Worktree:** `/Users/andrey.epifancev/projects/jira-bridge/jira-hub/.wt/feed`, ветка `feat/77-feed` (aep/jira-hub#77). + +## Global Constraints + +- Бэкенд (`src/jira_hub/`) не меняется. +- Тексты событий — ровно по спеке: «статус «было» → «стало»», «описание изменено», «срок ДД.ММ → ДД.ММ» / «плановое начало …» / «плановое окончание …», «комментарий: <300 символов…>», «<подпись>: «было» → «стало»» (значения до 80 символов с «…», пустое — «—»). +- Подписи прочих полей: `assignee` исполнитель, `Sprint` спринт, `labels` метки, `priority` приоритет, `summary` тема, `epic_link` эпик, `Link` связь, `Attachment` вложение, `resolution` резолюция; иначе имя поля как есть. +- Период: `2h` | `today` (по умолчанию, в адрес не пишется) | `yesterday` | `7d`; границы по местному времени, в запрос — `toISOString()`. `limit=2000`. +- Виды: `status`, `description`, `dates`, `comment`, `other`; по умолчанию первые четыре (в адрес не пишутся); подписи флажков «Статусы», «Описание», «Сроки», «Комментарии», «Прочие поля». +- Цвета и отступы — только токены дизайн-системы (`styles/tokens.css`), базовые компоненты из `web/src/ui/`; `dangerouslySetInnerHTML` не появляется; новых зависимостей нет. +- Коммиты — с `(#77)` и строкой `Co-Authored-By: Claude Opus 5.5 `. Зелёный — `make check` и `cd web && npm run e2e`. +- Решения вне спеки и плана — в `openspec/changes/web-feed/design.md`, раздел «Decisions made without discussion», маркер `R-n`. + +## Review Focus + +1. Ответ хаба без `changes` у карточки (старый хаб) и пустой `changes: []` — карточка не падает: нет блока / «изменений нет» (тесты в Task 3). +2. Событие с `at = null`, `author = null`, `from`/`to` = null — строка без «null»/«Invalid Date» (тест в Task 1). +3. Адрес с мусором `period=foo`, `kinds=status,foo` — страница не падает: период по умолчанию, неизвестные виды отброшены (тест в Task 1). +4. Смена проекта в панели на открытой ленте — запрос по новому проекту, период и виды из адреса сохраняются (тест в Task 2). +5. Длинный однословный текст комментария/значения — не ломает ширину (CSS `overflow-wrap: anywhere`) и нет горизонтальной прокрутки на 390 px (e2e в Task 2). + +--- + +### Task 1: `feed/events.ts`, тип события, запрос `useChanges`, перечитывание + +**Files:** +- Create: `web/src/feed/events.ts`, `web/src/feed/events.test.ts` +- Modify: `web/src/api/types.ts` (`ChangeEvent`, `ChangesResponse`, `IssueDetail.changes?: ChangeEvent[]`), `web/src/api/queries.ts` (`qk.changes`, `useChanges`), `web/src/shell/RefreshProvider.tsx` (инвалидация `["changes"]`), `web/src/shell/refresh.test.tsx` (ожидание) + +**Interfaces (produces):** +- `type ChangeKind = "status" | "description" | "dates" | "comment" | "other"`; `interface ChangeEvent { at: string | null; key?: string; summary?: string | null; status?: string | null; kind: ChangeKind; field: string; author: string | null; from: string | null; to: string | null; from_value: string | null; to_value: string | null }`; `interface ChangesResponse { since: string; until: string; truncated: boolean; changes: ChangeEvent[] }`. +- `type Period = "2h" | "today" | "yesterday" | "7d"`; `PERIODS: { value: Period; label: string }[]` (2 часа, Сегодня, Вчера, 7 дней); `parsePeriod(v: string | null): Period`; `periodRange(p: Period, now: Date): { since: Date; until: Date }`. +- `KINDS: { value: ChangeKind; label: string }[]`, `DEFAULT_KINDS`; `parseKinds(v: string | null): ChangeKind[]` (неизвестные отброшены, `null`/пусто → по умолчанию); `kindsParam(k: ChangeKind[]): string | null` (`null` для набора по умолчанию). +- `eventTime(at: string | null, now: Date, alwaysDate = false): string` (`ЧЧ:ММ` / `ДД.ММ ЧЧ:ММ` / «—»); `eventText(e: ChangeEvent): string`; `groupByIssue(events: ChangeEvent[]): { key: string; summary: string | null; status: string | null; events: ChangeEvent[] }[]`. +- `useChanges(q: { project: string; since: string; until: string; kinds: string }, enabled: boolean)` → `useQuery`, ключ `qk.changes(q) = ["changes", q]`, `limit: 2000`. + +- [ ] **Step 1: Tests** — `events.test.ts`: `periodRange` для всех четырёх при `now = new Date(2026, 9, 22, 14, 5)` (today → 22.10 00:00–14:05; yesterday → 21.10 00:00–22.10 00:00; 2h → 12:05; 7d → 15.10 14:05); `parsePeriod("foo") === "today"`; `parseKinds("status,foo") → ["status"]`, `parseKinds(null)` → по умолчанию; `kindsParam(DEFAULT_KINDS) === null`; `eventText` на каждом виде (статус, описание, срок по `from_value` `2026-10-23` → «срок 23.10 → —», плановое окончание со строкой без ISO-значения, комментарий 350 символов → 300 + «…», прочее `assignee` → «исполнитель: «—» → «ivanov»», незнакомое поле — как есть); `eventTime(null, …) === "—"`, сегодня — `ЧЧ:ММ`, вчера — `21.10 ЧЧ:ММ`, `alwaysDate` — с датой; `groupByIssue` — порядок групп по первому появлению. `refresh.test.tsx` — после пакета инвалидируется и `["changes"]`. +- [ ] **Step 2: Run** `cd web && npx vitest run src/feed src/shell/refresh.test.tsx` — FAIL +- [ ] **Step 3: Implement** +- [ ] **Step 4: Run** `make check` — зелёный +- [ ] **Step 5: Commit** — `Лента: события, период и запрос /changes (#77)` + +### Task 2: Страница «Лента», раздел в панели + +**Files:** +- Create: `web/src/feed/FeedPage.tsx`, `web/src/feed/EventList.tsx`, `web/src/feed/feed.css`, `web/src/feed/FeedPage.test.tsx`, `web/e2e/feed.spec.ts` +- Modify: `web/src/shell/pages.tsx` (`/feed` под `ProjectGate`), `web/src/shell/Sidebar.tsx` (`Section` + «Лента», значок `IconHistory` в `ui/icons.tsx`), `web/src/shell/Layout.tsx` (если там список разделов для `section`), `web/src/shell/Sidebar.test.tsx` + +**Interfaces:** +- `FeedPage({ project }: { project: string })` — `PageHeader` «Лента» с подзаголовком «N событий · ДД.ММ ЧЧ:ММ — ДД.ММ ЧЧ:ММ», `Segmented` периода (кнопки, запись в адрес через `setSearchParams`), флажки видов (`Checkbox`), `EventList` по группам; пустые/ошибка/truncated — `Notice` с текстами спеки. +- `EventList({ events, now, alwaysDate }: { events: ChangeEvent[]; now: Date; alwaysDate?: boolean })` — `
    ` строк «время · автор · текст». +- Группа: `IssueLink` с ключом, тема, `Badge` статуса. + +- [ ] **Step 1: Tests** — `FeedPage.test.tsx` (через `App` и `mockFetch`, как `Sidebar.test.tsx`): сценарии спеки «Что закрылось за два часа» (адрес и параметры запроса), «Карточка из ленты» (клик по ключу → диалог карточки, закрытие → лента с тем же `period`), «Пустой период», `truncated`, ошибка 500 с «Повторить», все флажки сняты → подсказка и нет запроса; Review Focus 3 (`period=foo&kinds=status,foo`) и 4 (смена проекта). `Sidebar.test.tsx` — раздел «Лента» ведёт на `/feed?project=…` и отмечается `aria-current`. `e2e/feed.spec.ts` — засеять журнал агентским `POST /agent/history` (элементы с `created` = сейчас, ключи из фикстуры `hub-basic`), открыть «Лента», увидеть событие, открыть карточку; на 390 px нет горизонтальной прокрутки. +- [ ] **Step 2: Run** `cd web && npx vitest run src/feed src/shell` — FAIL +- [ ] **Step 3: Implement** +- [ ] **Step 4: Run** `make check` и `cd web && npm run e2e` — зелёные +- [ ] **Step 5: Commit** — `Страница «Лента» и раздел в панели (#77)` + +### Task 3: «История» в карточке, документация + +**Files:** +- Modify: `web/src/issues/IssueCard.tsx` (блок `History` после `Comments`), `web/src/issues/IssueCard.test.tsx`, `ARCHITECTURE.md` (раздел `web/`: страница «Лента», `feed/`), `CLAUDE.md` репозитория (адрес `/app/feed` в «Команды») + +- [ ] **Step 1: Tests** — сценарий спеки «Карточка с историей» (12 событий → 10 + «Показать все (12)» → 12), `changes: []` → «изменений нет», без поля `changes` → нет заголовка «История» (Review Focus 1); строки — с датой (`alwaysDate`). +- [ ] **Step 2: Run** `cd web && npx vitest run src/issues/IssueCard.test.tsx` — FAIL +- [ ] **Step 3: Implement**, документация +- [ ] **Step 4: Run** `make check` и `cd web && npm run e2e` — зелёные +- [ ] **Step 5: Commit** — `История изменений в карточке задачи (#77)` diff --git a/openspec/changes/web-feed/.openspec.yaml b/openspec/changes/web-feed/.openspec.yaml new file mode 100644 index 0000000..7cc41bb --- /dev/null +++ b/openspec/changes/web-feed/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-22 diff --git a/openspec/changes/web-feed/design.md b/openspec/changes/web-feed/design.md new file mode 100644 index 0000000..eae81da --- /dev/null +++ b/openspec/changes/web-feed/design.md @@ -0,0 +1,27 @@ +## Context + +`main` 78a0d78. Веб-приложение — React 19 + TanStack Query, страницы рисует `shell/Layout.tsx` по `shell/pages.tsx`, +разделы — `shell/Sidebar.tsx` (`SECTIONS`), проект — `ProjectGate`, карточка — окно `IssueModal` поверх страницы +(`IssueLink`/`useOpenIssue` с `openCardState`). Запросы — `api/queries.ts` (`qk`), перечитывание после «Обновить из +Jira» — `shell/RefreshProvider.tsx`. Хаб: `GET /changes` (`since`/`until` — ISO со смещением, иначе 422; `kinds`, +`project`, `limit` ≤ 2000, ответ `{since, until, truncated, changes}`), `GET /issues/{key}.changes`. + +## Decisions + +1. **Модуль `web/src/feed/`**: `events.ts` — чистые функции (границы периода, разбор `kinds` из адреса, текст события, + группировка по задаче), `EventList.tsx` — список событий (лента и карточка), `FeedPage.tsx` — страница, `feed.css`. +2. **Период — переключатель из четырёх, а не календарь**: покрывает «за пару часов» и «за сегодня» владельца; «Вчера» и + «7 дней» — утренний разбор и неделя. Границы считаются по местному времени браузера, в запрос уходят + `toISOString()` (UTC с `Z`) — хаб принимает `Z`, пояс не теряется. +3. **`limit` = 2000** (максимум хаба) — неделя активного проекта помещается; иначе `truncated` с подсказкой. +4. **Подписи прочих полей** — словарь в `events.ts`; незнакомые — имя поля Jira. +5. **Комментарий в ленте — простой текст** (300 символов одной строкой), без вики-разметки: лента — обзор, полный текст + — в карточке. +6. **История в карточке — 10 последних с «Показать все»**; блок в основной колонке после комментариев. +7. **Перечитывание**: ключ запроса `["changes", …]`; `RefreshProvider` после пакета инвалидирует `["changes"]`; TanStack + Query перечитывает при возврате на вкладку (`refetchOnWindowFocus` по умолчанию). + +## Decisions made without discussion + +1. 2026-10-22 — **все решения выше (1–7) — мои**: владелец сказал «начинай» без обсуждения интерфейса. Цена ошибки: + переделка вёрстки страницы; бэкенд не затронут. diff --git a/openspec/changes/web-feed/proposal.md b/openspec/changes/web-feed/proposal.md new file mode 100644 index 0000000..5313873 --- /dev/null +++ b/openspec/changes/web-feed/proposal.md @@ -0,0 +1,25 @@ +## Why + +Хаб уже хранит журнал изменений задач (change-history, #70): лента `GET /changes` и история задачи в +`GET /issues/{key}`, в MCP — инструмент `changes`. Владелец хочет видеть то же в веб-приложении: что закрылось и +поменялось за последние часы или за день, и как менялась задача — в её карточке. Решение владельца 22.10.2026: делать +веб-часть после редизайна (#69), теперь он закрыт; детали интерфейса владелец отдал на усмотрение («начинай»). + +## What Changes + +- Новый раздел «Лента» в боковой панели и страница `/app/feed`: период (2 часа / Сегодня / Вчера / 7 дней), флажки видов + событий, события сгруппированы по задаче, ключ открывает карточку окном. +- Блок «История» в карточке задачи. +- Бэкенд не меняется. + +## Non-Goals + +- Свой интервал дат (календарь) — переключателя периодов достаточно; при нужде — позже. +- Лента по всем проектам сразу и по соседям проекта. +- Уведомления о новых событиях, автообновление по таймеру. +- Отображение текста правки описания (было/стало) — только факт правки. + +## Anti-Goals + +- Лента молча показывает не тот период (часовой пояс). +- Страница или карточка ломается на старом хабе без `changes`. diff --git a/openspec/changes/web-feed/specs/web-app/spec.md b/openspec/changes/web-feed/specs/web-app/spec.md new file mode 100644 index 0000000..d05cde1 --- /dev/null +++ b/openspec/changes/web-feed/specs/web-app/spec.md @@ -0,0 +1,93 @@ +## MODIFIED Requirements + +### Requirement: Оболочка и адреса +Приложение SHALL открываться под префиксом `/app/` и иметь страницы `/app/board` (доска связей), `/app/issues` (список +задач), `/app/feed` (лента изменений, требование «Лента изменений») и `/app/issues/{key}` (карточка задачи — окно поверх страницы, требование «Карточка задачи»); `/app` и `/app/` SHALL вести на `/app/board`, неизвестный путь — +на страницу «не найдено» со ссылками на разделы. Боковая панель SHALL быть закреплена у левого края окна на всю его +высоту и MUST NOT прокручиваться вместе со страницей; страница занимает остальную ширину. Сверху вниз панель SHALL +содержать: название «Хаб Jira» и кнопку «Свернуть панель»; выпадающий список проектов («<метка> — <название>», текущий +отмечен, в конце «+ Новый проект…»; требование «Проект в приложении»); разделы «Доска связей», «Задачи» и «Лента» — вертикальным +списком ссылок со значками (текущий отмечен `aria-current="page"`); внизу — блок данных Jira с действием «Обновить из +Jira» (требование «Обновление из Jira»), переключатель темы (требование «Тема») и меню с действием «Сменить токен». +Свёрнутая панель SHALL быть узкой полосой с теми же действиями значками: «Развернуть панель», метка текущего проекта, +разделы, «Обновить из Jira» с точкой состояния агента, тема, меню; названия — в подсказках и `aria-label`. Свёртка +SHALL храниться в `localStorage` под ключом `jira-hub-sidebar` (`collapsed`; без записи — развёрнута). На экранах уже +640 px панели в потоке нет: сверху SHALL быть закреплена строка с кнопкой «Меню», названием и кнопкой «Обновить из +Jira»; «Меню» открывает развёрнутую панель поверх страницы, панель закрывается выбором раздела, клавишей Escape или +щелчком мимо неё. Горизонтальной прокрутки страницы на любой ширине нет. +Неожиданная ошибка отрисовки страницы SHALL показываться сообщением с кнопкой «Перезагрузить», не пустым экраном. + +#### Scenario: Переход между разделами +- **WHEN** на доске в боковой панели выбран раздел «Задачи» +- **THEN** адрес — `/app/issues` с текущим проектом, показан список задач; «назад» возвращает на доску + +#### Scenario: Раздел «Лента» +- **WHEN** на доске в боковой панели выбран раздел «Лента» +- **THEN** адрес — `/app/feed` с текущим проектом, показана лента изменений за сегодня; раздел отмечен в панели + +#### Scenario: Сменить токен +- **WHEN** в меню боковой панели выбрано «Сменить токен» +- **THEN** токен очищен, показан экран входа + +#### Scenario: Панель на месте при прокрутке +- **WHEN** доска прокручена до самого низа +- **THEN** боковая панель видна целиком на прежнем месте: разделы и «Обновить из Jira» доступны без прокрутки вверх + +#### Scenario: Свёрнутая панель +- **WHEN** нажата «Свернуть панель», страница перезагружена +- **THEN** панель свёрнута в полосу значков, доска шире; значок «Задачи» открывает список задач; «Развернуть панель» + возвращает полную панель + +#### Scenario: Узкий экран +- **WHEN** ширина окна 390 px, нажата «Меню», в панели выбран раздел «Задачи» +- **THEN** панель открылась поверх страницы, после выбора закрыта, показан список задач; горизонтальной прокрутки нет + +## ADDED Requirements + +### Requirement: Лента изменений +Страница `/app/feed?project=<метка>` SHALL показывать события журнала изменений своих задач текущего проекта за период +(`GET /changes` с `project`, `since`, `until`, `kinds`). Период SHALL выбираться переключателем «2 часа» / «Сегодня» / +«Вчера» / «7 дней» (по умолчанию «Сегодня»; границы — по местному времени браузера: «Сегодня» — с начала текущих суток +до сейчас, «Вчера» — с начала прошлых суток до начала текущих) и храниться в адресе (`period=2h|today|yesterday|7d`; +«Сегодня» в адрес не пишется). Виды событий SHALL выбираться флажками «Статусы», «Описание», «Сроки», «Комментарии», +«Прочие поля» (по умолчанию все, кроме «Прочие поля») и храниться в адресе (`kinds=` через запятую; набор по умолчанию в +адрес не пишется); снятые все флажки — сообщение «выберите хотя бы один вид», запроса нет. + +События SHALL быть сгруппированы по задаче; задачи — по времени последнего события, новые сверху. У группы: ключ — +ссылка на карточку задачи (окно поверх ленты, требование «Карточка задачи»), тема и текущий статус; под ней события по +времени, новые сверху, каждое — время (`ЧЧ:ММ`, с датой `ДД.ММ`, если событие не в текущих сутках), автор и текст: +статус — «статус «было» → «стало»»; описание — «описание изменено»; срок, плановое начало и окончание — «срок ДД.ММ → +ДД.ММ» (дата по значению `from_value`/`to_value`, иначе строка журнала; пустое — «—»); комментарий — «комментарий:» и +первые 300 символов текста одной строкой с «…»; прочие поля — «<название поля>: «было» → «стало»» (значения до 80 +символов с «…»), где название — подпись из словаря приложения (исполнитель, спринт, метки, приоритет, тема, эпик, связь, +вложение, резолюция), иначе имя поля Jira. Подзаголовок страницы — число событий и границы периода. Событий нет — +сообщение «за период изменений нет»; `truncated` — сообщение «показаны последние N событий — выберите период короче». +Ошибка хаба — сообщение с кнопкой «Повторить». Ленту SHALL перечитывать завершение «Обновить из Jira» (вместе со списками +задач) и возврат на вкладку браузера. + +#### Scenario: Что закрылось за два часа +- **WHEN** открыта лента проекта AWPCA, выбран период «2 часа», сняты все флажки, кроме «Статусы» +- **THEN** адрес — `/app/feed?project=AWPCA&period=2h&kinds=status`; запрос `GET /changes?project=AWPCA&since=<сейчас − 2 ч>&kinds=status`; + задачи сгруппированы, у каждой — события «ЧЧ:ММ автор: статус «В работе» → «Готово»» + +#### Scenario: Карточка из ленты +- **WHEN** в ленте нажат ключ AURORA-9002 +- **THEN** поверх ленты открыто окно карточки AURORA-9002; закрытие окна возвращает ленту с тем же периодом + +#### Scenario: Пустой период +- **WHEN** за выбранный период событий нет +- **THEN** показано «за период изменений нет» + +#### Scenario: Вчера +- **WHEN** выбран период «Вчера», сейчас 22.10 14:05 местного времени +- **THEN** запрос с `since` = 21.10 00:00 и `until` = 22.10 00:00 местного времени + +### Requirement: История в карточке задачи +Карточка задачи SHALL показывать в основной колонке после комментариев блок «История (N)» — события `changes` из +`GET /issues/{key}` (все виды, новые сверху) в том же виде, что в ленте, всегда с датой. Видны 10 последних событий, +остальные — по кнопке «Показать все (N)»; без событий — подсказка «изменений нет»; ответ хаба без поля `changes` — +блока нет. + +#### Scenario: Карточка с историей +- **WHEN** открыта карточка задачи с 12 событиями журнала +- **THEN** блок «История (12)» показывает 10 последних событий и кнопку «Показать все (12)»; после нажатия — все 12 From 6f5fae7d823f8dcebee99b9bc40cf9712ab36897 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 22 Oct 2026 11:18:06 +0400 Subject: [PATCH 2/6] =?UTF-8?q?=D0=9B=D0=B5=D0=BD=D1=82=D0=B0:=20=D1=81?= =?UTF-8?q?=D0=BE=D0=B1=D1=8B=D1=82=D0=B8=D1=8F,=20=D0=BF=D0=B5=D1=80?= =?UTF-8?q?=D0=B8=D0=BE=D0=B4=20=D0=B8=20=D0=B7=D0=B0=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D1=81=20/changes=20(#77)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- web/src/api/queries.ts | 9 ++- web/src/api/types.ts | 9 +++ web/src/feed/events.test.ts | 79 +++++++++++++++++++++++++++ web/src/feed/events.ts | 91 +++++++++++++++++++++++++++++++ web/src/shell/RefreshProvider.tsx | 2 +- web/src/shell/refresh.test.tsx | 4 +- 6 files changed, 190 insertions(+), 4 deletions(-) create mode 100644 web/src/feed/events.test.ts create mode 100644 web/src/feed/events.ts diff --git a/web/src/api/queries.ts b/web/src/api/queries.ts index 0ec0b20..3e62024 100644 --- a/web/src/api/queries.ts +++ b/web/src/api/queries.ts @@ -1,6 +1,6 @@ import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { api } from "./client.ts"; -import type { BoardSnapshot, CreatedProject, HubSettings, IssueDetail, IssueRow, Note, NoteFields, Project } from "./types.ts"; +import type { BoardSnapshot, ChangesResponse, CreatedProject, HubSettings, IssueDetail, IssueRow, Note, NoteFields, Project } from "./types.ts"; // фильтры списка в адресе; hide_closed в адрес не пишется — его выводит список из флажка «скрыть закрытые» (closed=1 — снят) export const ISSUE_FILTER_NAMES = ["sprint", "assignee", "work_kind", "status", "label", "epic", "neighbor", "q"] as const; @@ -14,8 +14,15 @@ export const qk = { boardAll: ["board"] as const, // префикс: все снимки досок сразу (инвалидация) issues: (f: IssueFilters) => ["issues", f] as const, issue: (key: string) => ["issue", key] as const, + changes: (q: ChangesQuery) => ["changes", q] as const, }; +// лента изменений: границы — ISO (toISOString), kinds — через запятую; limit — максимум хаба (design web-feed #3) +export interface ChangesQuery { project: string; since: string; until: string; kinds: string } +export const CHANGES_LIMIT = 2000; +export const useChanges = (q: ChangesQuery, enabled = true) => + useQuery({ queryKey: qk.changes(q), enabled, queryFn: () => api.get("/changes", { ...q, limit: CHANGES_LIMIT }) }); + export const useSettings = () => useQuery({ queryKey: qk.settings, queryFn: () => api.get("/settings"), staleTime: Infinity }); diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 5a7fda7..6920881 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -41,6 +41,14 @@ export interface IssueLinkOut { target_key: string; link_type: string; summary: export interface IssueLinkIn { source_key: string; link_type: string; summary: string | null; status: string | null } // комментарий задачи из Jira (зеркало хаба, агент v10+): текст и время — как в Jira export interface IssueComment { comment_id: string; author: string | null; created: string | null; updated: string | null; body: string } +// событие журнала изменений (GET /changes, GET /issues/{key}.changes; в истории задачи нет key/summary/status) +export type ChangeKind = "status" | "description" | "dates" | "comment" | "other"; +export interface ChangeEvent { + at: string | null; key?: string; summary?: string | null; status?: string | null; kind: ChangeKind; field: string; + author: string | null; from: string | null; to: string | null; from_value: string | null; to_value: string | null; +} +export interface ChangesResponse { since: string; until: string; truncated: boolean; changes: ChangeEvent[] } + export interface IssueDetail extends IssueRow { epic_summary: string | null; links_out: IssueLinkOut[]; @@ -48,6 +56,7 @@ export interface IssueDetail extends IssueRow { note: Note | null; commands: IssueCommand[]; comments: IssueComment[]; + changes?: ChangeEvent[]; // хаб до журнала изменений (#70) поля не шлёт } export type BatchStatus = "queued" | "running" | "done" | "cancelled"; diff --git a/web/src/feed/events.test.ts b/web/src/feed/events.test.ts new file mode 100644 index 0000000..35685ce --- /dev/null +++ b/web/src/feed/events.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; +import type { ChangeEvent } from "../api/types.ts"; +import { DEFAULT_KINDS, eventText, eventTime, groupByIssue, kindsParam, parseKinds, parsePeriod, periodRange } from "./events.ts"; + +const NOW = new Date(2026, 9, 22, 14, 5); +const ev = (over: Partial = {}): ChangeEvent => ({ + at: "2026-10-22T08:15:00Z", key: "X", summary: "Тема", status: "Готово", kind: "status", field: "status", + author: "Петров", from: "В работе", to: "Готово", from_value: null, to_value: null, ...over, +}); + +describe("период", () => { + it("границы по местному времени", () => { + expect(periodRange("today", NOW)).toEqual({ since: new Date(2026, 9, 22, 0, 0), until: NOW }); + expect(periodRange("yesterday", NOW)).toEqual({ since: new Date(2026, 9, 21, 0, 0), until: new Date(2026, 9, 22, 0, 0) }); + expect(periodRange("2h", NOW)).toEqual({ since: new Date(2026, 9, 22, 12, 5), until: NOW }); + expect(periodRange("7d", NOW)).toEqual({ since: new Date(2026, 9, 15, 14, 5), until: NOW }); + }); + + it("мусор в адресе — «Сегодня»", () => { + expect(parsePeriod("foo")).toBe("today"); + expect(parsePeriod(null)).toBe("today"); + expect(parsePeriod("7d")).toBe("7d"); + }); +}); + +describe("виды", () => { + it("неизвестные отброшены, пусто — по умолчанию", () => { + expect(parseKinds("status,foo")).toEqual(["status"]); + expect(parseKinds(null)).toEqual(DEFAULT_KINDS); + expect(parseKinds("")).toEqual(DEFAULT_KINDS); + expect(parseKinds("foo")).toEqual([]); + }); + + it("набор по умолчанию в адрес не пишется", () => { + expect(kindsParam([...DEFAULT_KINDS])).toBeNull(); + expect(kindsParam(["comment", "status"])).toBe("status,comment"); + }); +}); + +describe("текст события", () => { + it("статус, описание, сроки", () => { + expect(eventText(ev())).toBe("статус «В работе» → «Готово»"); + expect(eventText(ev({ kind: "description", field: "description" }))).toBe("описание изменено"); + expect(eventText(ev({ kind: "dates", field: "duedate", from: "2026-10-23 00:00:00.0", to: null, from_value: "2026-10-23", to_value: null }))) + .toBe("срок 23.10 → —"); + expect(eventText(ev({ kind: "dates", field: "planned_end", from: "сырое", to: "30/окт/26", from_value: null, to_value: "2026-10-30" }))) + .toBe("плановое окончание сырое → 30.10"); + expect(eventText(ev({ kind: "dates", field: "planned_start", from: null, to: null }))).toBe("плановое начало — → —"); + }); + + it("комментарий — 300 символов одной строкой", () => { + const t = eventText(ev({ kind: "comment", field: "comment", from: null, to: "а\n" + "к".repeat(350) })); + expect(t).toBe("комментарий: " + ("а " + "к".repeat(350)).slice(0, 299) + "…"); + }); + + it("прочие поля — подпись из словаря или имя поля", () => { + expect(eventText(ev({ kind: "other", field: "assignee", from: null, to: "ivanov" }))).toBe("исполнитель: «—» → «ivanov»"); + expect(eventText(ev({ kind: "other", field: "Компания", from: "А", to: "Б" }))).toBe("Компания: «А» → «Б»"); + expect(eventText(ev({ kind: "other", field: "summary", from: "x".repeat(100), to: "y" }))) + .toBe(`тема: «${"x".repeat(79)}…» → «y»`); + }); +}); + +describe("время события", () => { + it("сегодня — ЧЧ:ММ, иначе с датой, без времени — прочерк", () => { + expect(eventTime(new Date(2026, 9, 22, 11, 15).toISOString(), NOW)).toBe("11:15"); + expect(eventTime(new Date(2026, 9, 21, 9, 5).toISOString(), NOW)).toBe("21.10 09:05"); + expect(eventTime(new Date(2026, 9, 22, 11, 15).toISOString(), NOW, true)).toBe("22.10 11:15"); + expect(eventTime(null, NOW)).toBe("—"); + expect(eventTime("мусор", NOW)).toBe("—"); + }); +}); + +describe("группировка", () => { + it("группы в порядке первого появления, у группы тема и статус", () => { + const g = groupByIssue([ev({ key: "Y", summary: "Вторая", status: null }), ev(), ev({ key: "Y", summary: "Вторая", status: null })]); + expect(g.map((x) => [x.key, x.summary, x.status, x.events.length])).toEqual([["Y", "Вторая", null, 2], ["X", "Тема", "Готово", 1]]); + }); +}); diff --git a/web/src/feed/events.ts b/web/src/feed/events.ts new file mode 100644 index 0000000..19535c3 --- /dev/null +++ b/web/src/feed/events.ts @@ -0,0 +1,91 @@ +import type { ChangeEvent, ChangeKind } from "../api/types.ts"; + +// период ленты: границы по местному времени браузера; «Сегодня» — по умолчанию и в адрес не пишется (spec web-app «Лента изменений») +export type Period = "2h" | "today" | "yesterday" | "7d"; +export const PERIODS: { value: Period; label: string }[] = [ + { value: "2h", label: "2 часа" }, { value: "today", label: "Сегодня" }, { value: "yesterday", label: "Вчера" }, { value: "7d", label: "7 дней" }, +]; +export const parsePeriod = (v: string | null): Period => (PERIODS.some((p) => p.value === v) ? (v as Period) : "today"); + +export function periodRange(p: Period, now: Date): { since: Date; until: Date } { + const midnight = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + switch (p) { + case "2h": return { since: new Date(now.getTime() - 2 * 3600_000), until: now }; + case "yesterday": return { since: new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1), until: midnight }; + case "7d": return { since: new Date(now.getFullYear(), now.getMonth(), now.getDate() - 7, now.getHours(), now.getMinutes(), now.getSeconds(), now.getMilliseconds()), until: now }; + default: return { since: midnight, until: now }; + } +} + +export const KINDS: { value: ChangeKind; label: string }[] = [ + { value: "status", label: "Статусы" }, { value: "description", label: "Описание" }, { value: "dates", label: "Сроки" }, + { value: "comment", label: "Комментарии" }, { value: "other", label: "Прочие поля" }, +]; +export const DEFAULT_KINDS: ChangeKind[] = ["status", "description", "dates", "comment"]; + +// виды из адреса: неизвестные отбрасываются, пусто — набор по умолчанию +export function parseKinds(v: string | null): ChangeKind[] { + if (!v) return [...DEFAULT_KINDS]; + const asked = v.split(",").map((s) => s.trim()); + return KINDS.map((k) => k.value).filter((k) => asked.includes(k)); +} + +// в адрес — в порядке KINDS; набор по умолчанию не пишется +export function kindsParam(kinds: ChangeKind[]): string | null { + const ordered = KINDS.map((k) => k.value).filter((k) => kinds.includes(k)); + return ordered.join(",") === DEFAULT_KINDS.join(",") ? null : ordered.join(","); +} + +const pad = (n: number) => String(n).padStart(2, "0"); + +// «ЧЧ:ММ» для событий текущих суток, иначе «ДД.ММ ЧЧ:ММ»; без времени — «—» +export function eventTime(at: string | null, now: Date, alwaysDate = false): string { + const d = at ? new Date(at) : null; + if (!d || isNaN(d.getTime())) return "—"; + const hm = `${pad(d.getHours())}:${pad(d.getMinutes())}`; + const sameDay = d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth() && d.getDate() === now.getDate(); + return sameDay && !alwaysDate ? hm : `${pad(d.getDate())}.${pad(d.getMonth() + 1)} ${hm}`; +} + +const FIELD_LABELS: Record = { + assignee: "исполнитель", Sprint: "спринт", labels: "метки", priority: "приоритет", summary: "тема", epic_link: "эпик", + Link: "связь", Attachment: "вложение", resolution: "резолюция", +}; +const DATE_LABELS: Record = { duedate: "срок", planned_start: "плановое начало", planned_end: "плановое окончание" }; + +const oneLine = (s: string | null) => (s ?? "").replace(/\s+/g, " ").trim(); +const cut = (s: string, n: number) => (s.length <= n ? s : s.slice(0, n - 1) + "…"); +const dash = (s: string | null) => (s ? s : "—"); + +// дата события сроков: по значению вида ГГГГ-ММ-ДД, иначе строка журнала как есть +function day(value: string | null, text: string | null): string { + const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(value ?? ""); + return m ? `${m[3]}.${m[2]}` : dash(text); +} + +export function eventText(e: ChangeEvent): string { + switch (e.kind) { + case "status": return `статус «${dash(e.from)}» → «${dash(e.to)}»`; + case "description": return "описание изменено"; + case "dates": return `${DATE_LABELS[e.field] ?? e.field} ${day(e.from_value, e.from)} → ${day(e.to_value, e.to)}`; + case "comment": return "комментарий: " + cut(oneLine(e.to), 300); + default: { + const v = (s: string | null) => (s ? cut(oneLine(s), 80) : "—"); + return `${FIELD_LABELS[e.field] ?? e.field}: «${v(e.from)}» → «${v(e.to)}»`; + } + } +} + +export interface IssueGroup { key: string; summary: string | null; status: string | null; events: ChangeEvent[] } + +// лента уже новые сверху: группы — в порядке первого появления задачи +export function groupByIssue(events: ChangeEvent[]): IssueGroup[] { + const groups = new Map(); + for (const e of events) { + const key = e.key ?? ""; + let g = groups.get(key); + if (!g) groups.set(key, (g = { key, summary: e.summary ?? null, status: e.status ?? null, events: [] })); + g.events.push(e); + } + return [...groups.values()]; +} diff --git a/web/src/shell/RefreshProvider.tsx b/web/src/shell/RefreshProvider.tsx index 0f6c1b9..108672f 100644 --- a/web/src/shell/RefreshProvider.tsx +++ b/web/src/shell/RefreshProvider.tsx @@ -95,7 +95,7 @@ export function RefreshProvider({ children, pollMs = POLL_MS }: { children: Reac skipPickup.current = qc.getQueryCache().findAll({ queryKey: qk.boardAll, type: "active" }).length; // 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 }); + for (const key of [["issues"], ["issue"], ["changes"], qk.projects]) void qc.invalidateQueries({ queryKey: key }); }, [qc]); useEffect(() => { diff --git a/web/src/shell/refresh.test.tsx b/web/src/shell/refresh.test.tsx index aca0856..2c025de 100644 --- a/web/src/shell/refresh.test.tsx +++ b/web/src/shell/refresh.test.tsx @@ -180,7 +180,7 @@ describe("RefreshProvider", () => { await waitFor(() => expect(h.count("GET /issues")).toBe(2)); }); - it("инвалидируются ключи board, issues, issue, projects (шпион)", async () => { + it("инвалидируются ключи board, issues, issue, projects, changes (шпион)", async () => { const client = makeQueryClient(); const spy = vi.spyOn(client, "invalidateQueries"); hub({ "GET /batches/:id": () => batch("done") }); @@ -188,7 +188,7 @@ describe("RefreshProvider", () => { await act(() => result.current.start()); await waitFor(() => expect(result.current.event).toEqual({ kind: "batch-done", id: 7 })); const keys = spy.mock.calls.map(([f]) => (f as { queryKey: unknown[] }).queryKey[0]); - expect(keys).toEqual(expect.arrayContaining(["board", "issues", "issue", "projects"])); + expect(keys).toEqual(expect.arrayContaining(["board", "issues", "issue", "projects", "changes"])); }); it("track(9) опрашивает пакет 9 без POST /refresh", async () => { From 37f28c38121462a671dcf80482004d172de88824 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 22 Oct 2026 11:26:10 +0400 Subject: [PATCH 3/6] =?UTF-8?q?=D0=A1=D1=82=D1=80=D0=B0=D0=BD=D0=B8=D1=86?= =?UTF-8?q?=D0=B0=20=C2=AB=D0=9B=D0=B5=D0=BD=D1=82=D0=B0=C2=BB=20=D0=B8=20?= =?UTF-8?q?=D1=80=D0=B0=D0=B7=D0=B4=D0=B5=D0=BB=20=D0=B2=20=D0=BF=D0=B0?= =?UTF-8?q?=D0=BD=D0=B5=D0=BB=D0=B8=20(#77)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- web/e2e/feed.spec.ts | 44 ++++++++++ web/src/feed/EventList.tsx | 20 +++++ web/src/feed/FeedPage.test.tsx | 152 +++++++++++++++++++++++++++++++++ web/src/feed/FeedPage.tsx | 79 +++++++++++++++++ web/src/feed/feed.css | 16 ++++ web/src/shell/Layout.tsx | 9 +- web/src/shell/Sidebar.test.tsx | 10 +++ web/src/shell/Sidebar.tsx | 5 +- web/src/shell/pages.tsx | 2 + web/src/ui/icons.tsx | 1 + 10 files changed, 333 insertions(+), 5 deletions(-) create mode 100644 web/e2e/feed.spec.ts create mode 100644 web/src/feed/EventList.tsx create mode 100644 web/src/feed/FeedPage.test.tsx create mode 100644 web/src/feed/FeedPage.tsx create mode 100644 web/src/feed/feed.css 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(<>); From 066cb79c4f01d947fe4756ef265f9f00425d6934 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 22 Oct 2026 11:31:39 +0400 Subject: [PATCH 4/6] =?UTF-8?q?=D0=98=D1=81=D1=82=D0=BE=D1=80=D0=B8=D1=8F?= =?UTF-8?q?=20=D0=B8=D0=B7=D0=BC=D0=B5=D0=BD=D0=B5=D0=BD=D0=B8=D0=B9=20?= =?UTF-8?q?=D0=B2=20=D0=BA=D0=B0=D1=80=D1=82=D0=BE=D1=87=D0=BA=D0=B5=20?= =?UTF-8?q?=D0=B7=D0=B0=D0=B4=D0=B0=D1=87=D0=B8=20(#77)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- ARCHITECTURE.md | 5 +++-- CLAUDE.md | 2 +- web/e2e/feed.spec.ts | 4 +++- web/src/issues/IssueCard.test.tsx | 32 +++++++++++++++++++++++++++++++ web/src/issues/IssueCard.tsx | 20 ++++++++++++++++++- 5 files changed, 58 insertions(+), 5 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 00f1b26..0150036 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -24,8 +24,9 @@ | `importer` | разовый импорт CSV-пакета в заметки, сверка с зеркалом | `mirror`, `settings` | | `cli` | `init`, `serve`, `import`, `mcp`, `tokens`, `agent publish` | `api`, `mcp_server`, `importer`, `release`, `settings`, `db`, `paths` | -`web/` — веб-приложение хаба (React + TypeScript + Vite): вход по клиентскому токену, страницы «Доска» (`/app/board`) -и «Задачи» (`/app/issues`); текущий проект — `?project=<метка>` в адресе (`web/src/projects/`: контекст проекта, выпадающий список «Проект», окно «Новый проект», заглушка «проект не найден»; страницы пересоздаются по `key={project}`); карточка `/app/issues/{key}` — окно поверх страницы, с которой её открыли: `shell/Layout.tsx` рисует +`web/` — веб-приложение хаба (React + TypeScript + Vite): вход по клиентскому токену, страницы «Доска» (`/app/board`), +«Задачи» (`/app/issues`) и «Лента» (`/app/feed`: журнал изменений `GET /changes` за период; `web/src/feed/` — чистые +функции `events.ts` (период, виды, текст события, группировка) и `EventList.tsx`, общий с блоком «История» карточки); текущий проект — `?project=<метка>` в адресе (`web/src/projects/`: контекст проекта, выпадающий список «Проект», окно «Новый проект», заглушка «проект не найден»; страницы пересоздаются по `key={project}`); карточка `/app/issues/{key}` — окно поверх страницы, с которой её открыли: `shell/Layout.tsx` рисует страницу по адресу из `location.state.background` (`useRoutes` с явным адресом) и `issues/IssueModal.tsx` поверх неё. Собирается `make web` в `src/jira_hub/web/app/`; `api` отдаёт сборку без токена (`/app/assets/*` — файлы, прочие `/app…` — `index.html`, 503 без сборки), `/` и `/links` перенаправляет на `/app/board`. Данные — только через HTTP API с клиентским токеном; `mirror.board` отдаёт зеркало без преобразований, diff --git a/CLAUDE.md b/CLAUDE.md index 00ebcc6..e5d691f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,7 +24,7 @@ - `uv sync` — установка окружения. - `uv run jira-hub init --config PATH [--force]` — настройки проекта из `config.example.json`, создание токенов. - `uv run jira-hub serve [--host] [--port]` — локальный запуск хаба. -- Веб-приложение — `http://127.0.0.1:8790/app/` (доска — `/app/board?project=<метка>`, задачи — `/app/issues?project=<метка>`; вход по клиентскому токену); `/` и `/links` перенаправляют на доску. +- Веб-приложение — `http://127.0.0.1:8790/app/` (доска — `/app/board?project=<метка>`, задачи — `/app/issues?project=<метка>`, лента изменений — `/app/feed?project=<метка>`; вход по клиентскому токену); `/` и `/links` перенаправляют на доску. - `make web` — сборка приложения (после `git pull` на Mac mini, до перезапуска хаба); `make check` — pytest + lint и тесты фронта; `cd web && npm run dev` — разработка с прокси на тестовый хаб `bash web/scripts/test-hub.sh` (:8791); `cd web && npm run e2e` — Playwright; `cd web && npm run golden` — перегенерировать эталоны доски из `links.html@3931a88` (только при смене фикстур). - `uv run jira-hub import FOLDER` — импорт CSV-пакета `admin/jira-sync`; `uv run jira-hub mcp [--project <метка>]` — MCP по stdio (с привязкой сессии к проекту); `uv run jira-hub tokens` — показать токены. - `uv run jira-hub agent publish PATH` — опубликовать версию агента для самообновления (только вверх). diff --git a/web/e2e/feed.spec.ts b/web/e2e/feed.spec.ts index 6958e7d..049aba7 100644 --- a/web/e2e/feed.spec.ts +++ b/web/e2e/feed.spec.ts @@ -16,7 +16,7 @@ async function seedHistory() { expect(r.ok).toBe(true); } -test("лента: событие за сегодня, карточка окном поверх ленты", async ({ page }) => { +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 }) }); @@ -29,6 +29,8 @@ test("лента: событие за сегодня, карточка окно await group.getByRole("link", { name: "AURORA-9002", exact: true }).click(); const d = page.getByRole("dialog"); await expect(d.getByRole("heading", { name: "API свода" })).toBeVisible(); + await expect(d.getByRole("heading", { name: /^История \(\d+\)$/ })).toBeVisible(); + await expect(d.getByTestId("history").getByText("статус «В работе» → «Готово»").first()).toBeVisible(); await page.keyboard.press("Escape"); await expect(page.getByRole("dialog")).toHaveCount(0); await expect(page.getByTestId("page-feed")).toBeVisible(); diff --git a/web/src/issues/IssueCard.test.tsx b/web/src/issues/IssueCard.test.tsx index 5718ab2..8455af3 100644 --- a/web/src/issues/IssueCard.test.tsx +++ b/web/src/issues/IssueCard.test.tsx @@ -450,3 +450,35 @@ describe("карточка задачи", () => { err.mockRestore(); }); }); + +describe("история в карточке", () => { + beforeEach(() => { setToken("tok"); localStorage.clear(); }); + const hist = (n: number) => Array.from({ length: n }, (_, i) => ({ + at: new Date(2026, 9, 20, 9, i).toISOString(), kind: "status" as const, field: "status", author: "Петров", + from: "a", to: `s${i}`, from_value: null, to_value: null, + })); + + it("12 событий: видны 10 последних и «Показать все (12)», после нажатия — все", async () => { + mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { changes: hist(12) }))); + open("/issues/AURORA-9001"); + const sec = await screen.findByTestId("history"); + expect(within(sec).getByRole("heading", { name: "История (12)" })).toBeInTheDocument(); + expect(within(sec).getAllByRole("listitem")).toHaveLength(10); + expect(within(sec).getAllByRole("listitem")[0]).toHaveTextContent(/^20\.10 09:00/); // с датой всегда + await userEvent.click(within(sec).getByRole("button", { name: "Показать все (12)" })); + expect(within(sec).getAllByRole("listitem")).toHaveLength(12); + }); + + it("пустая история — «изменений нет»; без поля changes (старый хаб) — блока нет", async () => { + let body = issue("AURORA-9001", { changes: [] }); + mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, body)); + open("/issues/AURORA-9001"); + const sec = await screen.findByTestId("history"); + expect(within(sec).getByText("изменений нет")).toBeInTheDocument(); + cleanup(); + body = issue("AURORA-9002"); + open("/issues/AURORA-9002"); + await screen.findByRole("heading", { name: "Тема AURORA-9002" }); + expect(screen.queryByTestId("history")).toBeNull(); + }); +}); diff --git a/web/src/issues/IssueCard.tsx b/web/src/issues/IssueCard.tsx index 40c8942..1827131 100644 --- a/web/src/issues/IssueCard.tsx +++ b/web/src/issues/IssueCard.tsx @@ -2,9 +2,10 @@ import { type RefObject, useLayoutEffect, useRef, useState } from "react"; import { ApiError } from "../api/client.ts"; import { useClosedStatuses } from "../api/closed.ts"; import { useIssue, useSettings } from "../api/queries.ts"; -import type { IssueComment, IssueDetail } from "../api/types.ts"; +import type { ChangeEvent, IssueComment, IssueDetail } from "../api/types.ts"; import { JIRA_URL } from "../board/constants.ts"; import { fmtDate, fmtStamp } from "../board/model/util.ts"; +import { EventList } from "../feed/EventList.tsx"; import { groupLinks, isOverdue, todayISO } from "./card.ts"; import { useCommentsHidden } from "./commentsPref.ts"; import { IssueLink } from "./IssueLink.tsx"; @@ -69,6 +70,7 @@ export function IssueCard({ issueKey: key, onClose }: { issueKey: string; onClos } /> {i.description && } + {i.changes && } @@ -178,6 +180,22 @@ function Comments({ items, projectKey }: { items: IssueComment[]; projectKey?: s ); } +const HISTORY_SHOWN = 10; + +// журнал изменений задачи: 10 последних, остальное — по «Показать все» (spec web-app «История в карточке задачи») +function History({ items }: { items: ChangeEvent[] }) { + const [all, setAll] = useState(false); + const shown = all ? items : items.slice(0, HISTORY_SHOWN); + return ( +
    +

    История ({items.length})

    + {!items.length &&

    изменений нет

    } + {items.length > 0 && } + {!all && items.length > HISTORY_SHOWN && } +
    + ); +} + function OtherFields({ issue: i }: { issue: IssueDetail }) { const rows: [string, string | null][] = [ ["Тип", i.issue_type], ["Приоритет", i.priority], ["Автор", i.reporter], ["Метки", i.labels.join(", ") || null], From 820adbe92fd715699e2330e91f06169a48a79b5b Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 22 Oct 2026 11:41:51 +0400 Subject: [PATCH 5/6] =?UTF-8?q?=D0=9B=D0=B5=D0=BD=D1=82=D0=B0:=20=D0=BE?= =?UTF-8?q?=D0=BA=D0=BD=D0=BE=20=C2=AB=D0=B4=D0=BE=20=D1=81=D0=B5=D0=B9?= =?UTF-8?q?=D1=87=D0=B0=D1=81=C2=BB=20=D0=BF=D1=80=D0=B8=20=D0=BF=D0=B5?= =?UTF-8?q?=D1=80=D0=B5=D1=87=D0=B8=D1=82=D1=8B=D0=B2=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D0=B8=20=D0=B8=20=D0=BF=D0=B5=D1=80=D0=B5=D1=87=D0=B8=D1=82?= =?UTF-8?q?=D1=8B=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20=D0=BF=D1=80=D0=B8=20?= =?UTF-8?q?=D0=B2=D0=BE=D0=B7=D0=B2=D1=80=D0=B0=D1=82=D0=B5=20=D0=BD=D0=B0?= =?UTF-8?q?=20=D0=B2=D0=BA=D0=BB=D0=B0=D0=B4=D0=BA=D1=83=20(#77,=20C-1,=20?= =?UTF-8?q?C-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- openspec/changes/web-feed/design.md | 16 ++++++++++++++-- web/src/api/queries.ts | 16 ++++++++++++---- web/src/feed/FeedPage.test.tsx | 20 +++++++++++++++++++- web/src/feed/FeedPage.tsx | 17 ++++++++--------- 4 files changed, 53 insertions(+), 16 deletions(-) diff --git a/openspec/changes/web-feed/design.md b/openspec/changes/web-feed/design.md index eae81da..52b6675 100644 --- a/openspec/changes/web-feed/design.md +++ b/openspec/changes/web-feed/design.md @@ -18,10 +18,22 @@ Jira» — `shell/RefreshProvider.tsx`. Хаб: `GET /changes` (`since`/`until` 5. **Комментарий в ленте — простой текст** (300 символов одной строкой), без вики-разметки: лента — обзор, полный текст — в карточке. 6. **История в карточке — 10 последних с «Показать все»**; блок в основной колонке после комментариев. -7. **Перечитывание**: ключ запроса `["changes", …]`; `RefreshProvider` после пакета инвалидирует `["changes"]`; TanStack - Query перечитывает при возврате на вкладку (`refetchOnWindowFocus` по умолчанию). +7. **Перечитывание**: ключ запроса `["changes", {project, period, kinds}]`, границы периода считаются в момент запроса + (окно всегда «до сейчас»); `RefreshProvider` после пакета инвалидирует `["changes"]`; при возврате на вкладку — + `refetchOnWindowFocus: true` у `useChanges` (в клиенте приложения по умолчанию оно выключено). Подзаголовок берёт + окно из ответа хаба. ## Decisions made without discussion 1. 2026-10-22 — **все решения выше (1–7) — мои**: владелец сказал «начинай» без обсуждения интерфейса. Цена ошибки: переделка вёрстки страницы; бэкенд не затронут. +2. 2026-10-22 — **R-1: смена проекта на ленте сохраняет `period` и `kinds`** (`Layout.select`). Обоснование: они от + проекта не зависят; фильтры доски и списка по-прежнему сбрасываются. Цена ошибки: нет. +3. 2026-10-22 — **R-2: снятые все флажки пишутся в адрес как `kinds=none`.** Обоснование: пустое значение означало бы + набор по умолчанию. Цена ошибки: «none» виден в адресе. +4. 2026-10-22 — **R-3: в e2e флажок — щелчок и ожидание `toBeChecked`, а не `locator.check`.** Обоснование: флажок + управляемый, состояние приходит из адреса после навигации роутера. Цена ошибки: нет. +5. 2026-10-22 — **R-4 (по C-1/C-2 финального ревью): границы периода — в момент запроса, ключ кэша без них; + `refetchOnWindowFocus: true` у ленты.** Обоснование: замороженная при открытии граница «до» не давала перечитыванию + показать новые события, а фокус вкладки в приложении не перечитывал ничего. Цена ошибки: лишний запрос `/changes` + при возврате на вкладку (не чаще раза в 30 с — `staleTime`). diff --git a/web/src/api/queries.ts b/web/src/api/queries.ts index 3e62024..913b83c 100644 --- a/web/src/api/queries.ts +++ b/web/src/api/queries.ts @@ -17,11 +17,19 @@ export const qk = { changes: (q: ChangesQuery) => ["changes", q] as const, }; -// лента изменений: границы — ISO (toISOString), kinds — через запятую; limit — максимум хаба (design web-feed #3) -export interface ChangesQuery { project: string; since: string; until: string; kinds: string } +// лента изменений: в ключе — проект, период и виды; границы считаются в момент запроса (bounds), поэтому перечитывание +// (после «Обновить из Jira», при возврате на вкладку) берёт окно «до сейчас», а не до открытия страницы (C-1, C-2); +// limit — максимум хаба (design web-feed #3) +export interface ChangesQuery { project: string; period: string; kinds: string } export const CHANGES_LIMIT = 2000; -export const useChanges = (q: ChangesQuery, enabled = true) => - useQuery({ queryKey: qk.changes(q), enabled, queryFn: () => api.get("/changes", { ...q, limit: CHANGES_LIMIT }) }); +export const useChanges = (q: ChangesQuery, bounds: () => { since: Date; until: Date }, enabled = true) => + useQuery({ + queryKey: qk.changes(q), enabled, refetchOnWindowFocus: true, + queryFn: () => { + const { since, until } = bounds(); + return api.get("/changes", { project: q.project, kinds: q.kinds, since: since.toISOString(), until: until.toISOString(), limit: CHANGES_LIMIT }); + }, + }); export const useSettings = () => useQuery({ queryKey: qk.settings, queryFn: () => api.get("/settings"), staleTime: Infinity }); diff --git a/web/src/feed/FeedPage.test.tsx b/web/src/feed/FeedPage.test.tsx index 42e5421..4ae56f7 100644 --- a/web/src/feed/FeedPage.test.tsx +++ b/web/src/feed/FeedPage.test.tsx @@ -3,7 +3,9 @@ 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 { focusManager } from "@tanstack/react-query"; +import { act } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { App } from "../App.tsx"; import type { ChangeEvent } from "../api/types.ts"; import { setToken } from "../auth/token.ts"; @@ -51,6 +53,22 @@ const page = () => screen.findByTestId("page-feed"); describe("лента изменений", () => { beforeEach(() => { setToken("tok"); localStorage.clear(); }); + afterEach(() => { vi.useRealTimers(); focusManager.setFocused(undefined); }); + + it("C-1/C-2: возврат на вкладку перечитывает ленту с границей «до» на момент запроса", async () => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date(2026, 9, 22, 10, 0)); + const calls = hub(); + open("/app/feed?project=AWPCA"); + await page(); + await waitFor(() => expect(calls.length).toBe(1)); + expect(new Date(calls[0].searchParams.get("until")!).getTime()).toBe(new Date(2026, 9, 22, 10, 0).getTime()); + vi.setSystemTime(new Date(2026, 9, 22, 11, 30)); + act(() => { focusManager.setFocused(false); focusManager.setFocused(true); }); + await waitFor(() => expect(calls.length).toBe(2)); + expect(new Date(calls[1].searchParams.get("until")!).getTime()).toBe(new Date(2026, 9, 22, 11, 30).getTime()); + expect(new Date(calls[1].searchParams.get("since")!).getTime()).toBe(new Date(2026, 9, 22, 0, 0).getTime()); + }); it("по умолчанию — «Сегодня», виды без «Прочих полей», события сгруппированы по задаче", async () => { const calls = hub(() => ({ changes: [ diff --git a/web/src/feed/FeedPage.tsx b/web/src/feed/FeedPage.tsx index c10b390..7523c32 100644 --- a/web/src/feed/FeedPage.tsx +++ b/web/src/feed/FeedPage.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from "react"; +import { useMemo } from "react"; import { useSearchParams } from "react-router"; import { useChanges } from "../api/queries.ts"; import type { ChangeKind } from "../api/types.ts"; @@ -9,7 +9,7 @@ import { groupByIssue, KINDS, kindsParam, parseKinds, parsePeriod, PERIODS, peri 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())}`; +const stamp = (d: Date) => isNaN(d.getTime()) ? "—" : `${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; @@ -23,18 +23,16 @@ 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); + // границы считает запрос в момент выполнения (C-1): ключ кэша — проект, период и виды + const changes = useChanges({ project, period, kinds: kinds.join(",") }, () => periodRange(period, new Date()), kinds.length > 0); + const now = new Date(changes.dataUpdatedAt || Date.now()); // «текущие сутки» для времени событий — на момент ответа 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 choosePeriod = (p: Period) => 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», иначе адрес вернул бы умолчание @@ -43,7 +41,8 @@ export function FeedPage({ project }: { project: string }) { 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; + // окно — из ответа хаба: подзаголовок совпадает с тем, что показано + const subtitle = kinds.length && data ? `${count} ${plural(count)} · ${stamp(new Date(data.since))} — ${stamp(new Date(data.until))}` : undefined; return (
    From e0d8eee5b446487d4fd73d545a6805ef6dbf45bc Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 22 Oct 2026 12:09:52 +0400 Subject: [PATCH 6/6] =?UTF-8?q?=D0=9B=D0=B5=D0=BD=D1=82=D0=B0:=20follow-up?= =?UTF-8?q?=20=D1=80=D0=B5=D0=B2=D1=8C=D1=8E=20M-1=E2=80=A6M-7,=20README?= =?UTF-8?q?=20=D0=BF=D0=BE=20=D0=B6=D1=83=D1=80=D0=BD=D0=B0=D0=BB=D1=83=20?= =?UTF-8?q?=D0=B8=D0=B7=D0=BC=D0=B5=D0=BD=D0=B5=D0=BD=D0=B8=D0=B9=20(#77,?= =?UTF-8?q?=20#76)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- README.md | 13 ++++++++----- openspec/changes/web-feed/design.md | 3 +++ openspec/changes/web-feed/specs/web-app/spec.md | 3 ++- web/src/feed/FeedPage.test.tsx | 17 +++++++++++++++++ web/src/feed/FeedPage.tsx | 4 ++-- web/src/feed/events.test.ts | 6 ++++-- web/src/feed/events.ts | 14 +++++++++----- web/src/shell/Layout.tsx | 2 +- web/src/shell/NotFound.tsx | 2 +- web/src/shell/Sidebar.test.tsx | 1 + 10 files changed, 48 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 173de8a..3b6fdae 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,8 @@ claude mcp add -s user jira-hub -- uv run --project /Users/andrey.epifancev/proj | Инструмент | Параметры | Результат | |---|---|---| | `issues` | фильтры как у `GET /issues`, `limit` | таблица задач проекта сессии | -| `issue` | `key` | поля, связи, заметки, последние команды | +| `issue` | `key` | поля, связи, заметки, последние команды, комментарии, 5 последних изменений («изменения (N)») | +| `changes` | `hours`, `since`, `until` (ISO; без смещения — местное время), `kinds` (`status`, `description`, `dates`, `comment`, `other` или `all`), `key` | лента изменений по задачам: что закрылось, правки описания, сроки, новые комментарии; по умолчанию — с начала суток; без привязки — только с `key` | | `notes_set` | `key`, поля заметок | обновлённые заметки | | `batch_create` | `title`, `commands` (у `ensure_attachment` вместо `file` можно `path` — абсолютный путь к локальному файлу до 10 МБ) | `id`, оценка длительности, число команд | | `batch_status` | `id` | статусы команд, ошибки, оценка остатка | @@ -89,7 +90,7 @@ claude mcp add -s user jira-hub -- uv run --project /Users/andrey.epifancev/proj | `projects` | нет | проекты хаба: метка, название, доска, тип доски, число задач; проект сессии отмечен | | `project_create` | `label`, `name`, `board_id` (необязательно) | проект заведён, пакет обновления `#N`, готовый фрагмент `.mcp.json` для репозитория проекта | | `agent_status` | нет | онлайн/офлайн, версия, текущая команда, 429 за сутки | -| `sync_log` | `limit` | последние загрузки состояния | +| `sync_log` | `limit` | последние загрузки состояния и «журнал: принят / нет» | | `settings_get`, `settings_set` | ключ, значение | значение / новая `settings_version` | Автор команд из MCP: `claude`; из API без автора: `api`; из веб-приложения: `viewer`. @@ -170,12 +171,14 @@ uv run jira-hub agent publish ../jira-agent/src/agent.py | `GET /agent/commands/{id}` | агент | команда по id | | `POST /agent/commands/{id}/result` | агент | отчёт о выполнении (идемпотентно) | | `POST /agent/state` | агент | загрузка состояния задач пачками | +| `POST /agent/history` | агент | журнал изменений Jira пачками (агент v13+, по `history` в `refresh`) → `{accepted, duplicates}`; повторы отбрасываются, неверная пачка — 422 `bad_history` | | `GET /agent/known-keys` | агент | ключи зеркала | | `GET /agent/release` | агент | опубликованная версия агента `{version, sha256, content}`; 404 `no_release` | | `GET /agent/files/{sha256}` | агент | файл вложения `{file, size, content}` (`content` — base64); 404 `no_file` | | `GET /projects`, `POST /projects` | клиент | проекты `{projects: […]}` (метка, название, `board_id`, `board_type`, число задач) / завести `{label, name, board_id?, author?}` → 201 `{project, batch_id}` и пакет «Новый проект <метка>» (`refresh` по общей JQL); 409 `project_exists`, 422 `bad_project`, 422 `bad_batch` (постановка `refresh` отказана) | | `GET /issues` | клиент | фильтры `project` (метка; неизвестная — 404), `sprint`, `assignee`, `work_kind`, `fact_status`, `status`, `label`, `epic`, `neighbor`, `q`, `hide_closed` (`1` — без задач в статусах из настройки `closed_statuses`, по умолчанию «Готово», «Снято»), `limit`, `offset` | -| `GET /issues/{key}` | клиент | задача целиком | +| `GET /issues/{key}` | клиент | задача целиком, с комментариями и журналом изменений `changes` (новые сверху) | +| `GET /changes` | клиент | лента изменений: `since`, `until` (ISO со смещением или `Z`, иначе 422; по умолчанию — последние 24 ч), `project` (только свои задачи), `key`, `kinds`, `limit` (до 2000) → `{since, until, truncated, changes}` | | `GET /board` | клиент | `?project=<метка>` (неизвестная — 404): снимок для доски — свои задачи проекта, соседи (связи в обе стороны) и эпики своих задач, с `fact_status`/`work_kind`; связи — те, у которых хотя бы один конец — своя задача проекта (без `project` — все задачи и связи), `last_sync` — последний успешный полный прогон | | `GET /`, `GET /links` | — | перенаправление на `/app/board` | | `PUT /issues/{key}/note` | клиент | заметки (частично) | @@ -184,7 +187,7 @@ uv run jira-hub agent publish ../jira-agent/src/agent.py | `POST /commands/{id}/retry`, `POST /commands/{id}/cancel` | клиент | повтор (новый пакет; `project` в теле — отказ, если команда не из этого проекта) / отмена (`force` для взятой) | | `POST /refresh` | клиент | `{keys}`, `{jql}` или `{full: true}` (общая JQL по меткам всех проектов; проектов нет — 422 «нет проектов») | | `POST /files` | клиент | загрузить файл вложения `{content: base64}` → `{file: sha256, size}`; не base64 — 400, пустой — 422 `empty_file`, больше 10 МБ (10 485 760 байт) — 413 `too_large`; повтор того же содержимого — тот же `file` | -| `GET /agent`, `GET /sync-runs` | клиент | состояние агента (с `release`, `update_state`, `launcher_version`, `agent_reported`, `update_hint`), журнал загрузок | +| `GET /agent`, `GET /sync-runs` | клиент | состояние агента (с `release`, `update_state`, `launcher_version`, `agent_reported`, `update_hint`), журнал загрузок (`history_at` — когда принят журнал изменений полного прогона) | | `GET /settings`, `PUT /settings` | клиент | настройки (PUT частично, поднимает `settings_version`; ответ — все настройки после записи, как у GET); `base_jql` вычисляется из проектов (без проектов — `null`), запись `base_jql` и `board_id` — 422 | ```bash @@ -194,7 +197,7 @@ curl -s -H "Authorization: Bearer $TOKEN" "http://127.0.0.1:8790/issues?sprint= ## Веб-приложение -Адреса: `http://127.0.0.1:8790/app/` — доска `/app/board?project=<метка>`, задачи `/app/issues?project=<метка>`, карточка `/app/issues/{key}` — окно поверх списка или доски (закрытие — ✕, Esc, щелчок по фону; прямой адрес открывает окно поверх списка задач); `/` и +Адреса: `http://127.0.0.1:8790/app/` — доска `/app/board?project=<метка>`, задачи `/app/issues?project=<метка>`, лента изменений `/app/feed?project=<метка>` (период и виды событий, история — и в карточке задачи), карточка `/app/issues/{key}` — окно поверх списка или доски (закрытие — ✕, Esc, щелчок по фону; прямой адрес открывает окно поверх списка задач); `/` и `/links` перенаправляют на доску. Вход по клиентскому токену (`uv run jira-hub tokens`), токен хранится в браузере; `localhost` и `127.0.0.1` — разные сайты для браузера, токен и раскладка у каждого свои. Выкатка на Mac mini: diff --git a/openspec/changes/web-feed/design.md b/openspec/changes/web-feed/design.md index 52b6675..4cb2947 100644 --- a/openspec/changes/web-feed/design.md +++ b/openspec/changes/web-feed/design.md @@ -37,3 +37,6 @@ Jira» — `shell/RefreshProvider.tsx`. Хаб: `GET /changes` (`since`/`until` `refetchOnWindowFocus: true` у ленты.** Обоснование: замороженная при открытии граница «до» не давала перечитыванию показать новые события, а фокус вкладки в приложении не перечитывал ничего. Цена ошибки: лишний запрос `/changes` при возврате на вкладку (не чаще раза в 30 с — `staleTime`). +6. 2026-10-22 — **R-5 (follow-up M-5 финального ревью): адрес без единого известного вида — набор по умолчанию, а не + пустой; пустой набор — только явное `kinds=none`.** Обоснование: ссылка с устаревшим видом не должна открывать + пустую ленту. Цена ошибки: нет. diff --git a/openspec/changes/web-feed/specs/web-app/spec.md b/openspec/changes/web-feed/specs/web-app/spec.md index d05cde1..185a462 100644 --- a/openspec/changes/web-feed/specs/web-app/spec.md +++ b/openspec/changes/web-feed/specs/web-app/spec.md @@ -51,7 +51,8 @@ Jira»; «Меню» открывает развёрнутую панель по до сейчас, «Вчера» — с начала прошлых суток до начала текущих) и храниться в адресе (`period=2h|today|yesterday|7d`; «Сегодня» в адрес не пишется). Виды событий SHALL выбираться флажками «Статусы», «Описание», «Сроки», «Комментарии», «Прочие поля» (по умолчанию все, кроме «Прочие поля») и храниться в адресе (`kinds=` через запятую; набор по умолчанию в -адрес не пишется); снятые все флажки — сообщение «выберите хотя бы один вид», запроса нет. +адрес не пишется; в адресе нет ни одного известного вида — набор по умолчанию); снятые все флажки — сообщение «выберите +хотя бы один вид», запроса нет. События SHALL быть сгруппированы по задаче; задачи — по времени последнего события, новые сверху. У группы: ключ — ссылка на карточку задачи (окно поверх ленты, требование «Карточка задачи»), тема и текущий статус; под ней события по diff --git a/web/src/feed/FeedPage.test.tsx b/web/src/feed/FeedPage.test.tsx index 4ae56f7..0c996d0 100644 --- a/web/src/feed/FeedPage.test.tsx +++ b/web/src/feed/FeedPage.test.tsx @@ -119,6 +119,23 @@ describe("лента изменений", () => { expect(router.state.location.pathname + router.state.location.search).toBe("/app/feed?project=AWPCA&period=7d"); }); + it("M-1: /app/feed без project — проект дописывается в адрес", async () => { + hub(); + const router = open("/app/feed"); + await page(); + await waitFor(() => expect(router.state.location.search).toBe("?project=AWPCA")); + }); + + it("M-3/M-6/M-7: подсказка truncated над группами, автор без имени — «—», группы не регионы", async () => { + hub(() => ({ truncated: true, changes: [ev({ author: null })] })); + open("/app/feed?project=AWPCA"); + const notice = await screen.findByText("показаны последние 1 событий — выберите период короче"); + const group = screen.getByTestId("feed-group"); + expect(notice.compareDocumentPosition(group) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(within(group).getByText("—", { selector: ".feed-event__author" })).toBeInTheDocument(); + expect(within(screen.getByTestId("page-feed")).queryAllByRole("region")).toHaveLength(0); + }); + it("пустой период, truncated, ошибка с «Повторить»", async () => { let mode: "empty" | "truncated" | "error" = "empty"; hub(() => mode === "error" ? json(500, { error: "boom", detail: "сбой" }) diff --git a/web/src/feed/FeedPage.tsx b/web/src/feed/FeedPage.tsx index 7523c32..7315460 100644 --- a/web/src/feed/FeedPage.tsx +++ b/web/src/feed/FeedPage.tsx @@ -62,8 +62,9 @@ export function FeedPage({ project }: { project: string }) { )} {kinds.length > 0 && changes.isPending &&

    Загрузка…

    } {data && !count && за период изменений нет} + {data?.truncated && {`показаны последние ${count} событий — выберите период короче`}} {groups.map((g) => ( - +
    {g.summary ?? "нет в зеркале"} @@ -72,7 +73,6 @@ export function FeedPage({ project }: { project: string }) { ))} - {data?.truncated && {`показаны последние ${count} событий — выберите период короче`}}
    ); } diff --git a/web/src/feed/events.test.ts b/web/src/feed/events.test.ts index 35685ce..eee4757 100644 --- a/web/src/feed/events.test.ts +++ b/web/src/feed/events.test.ts @@ -28,7 +28,8 @@ describe("виды", () => { expect(parseKinds("status,foo")).toEqual(["status"]); expect(parseKinds(null)).toEqual(DEFAULT_KINDS); expect(parseKinds("")).toEqual(DEFAULT_KINDS); - expect(parseKinds("foo")).toEqual([]); + expect(parseKinds("foo")).toEqual(DEFAULT_KINDS); // M-5: ни одного известного — набор по умолчанию + expect(parseKinds("none")).toEqual([]); }); it("набор по умолчанию в адрес не пишется", () => { @@ -50,7 +51,8 @@ describe("текст события", () => { it("комментарий — 300 символов одной строкой", () => { const t = eventText(ev({ kind: "comment", field: "comment", from: null, to: "а\n" + "к".repeat(350) })); - expect(t).toBe("комментарий: " + ("а " + "к".repeat(350)).slice(0, 299) + "…"); + expect(t).toBe("комментарий: " + ("а " + "к".repeat(350)).slice(0, 300) + "…"); // M-4: 300 символов текста + expect(eventText(ev({ kind: "comment", field: "comment", from: null, to: null }))).toBe("комментарий: —"); // M-6 }); it("прочие поля — подпись из словаря или имя поля", () => { diff --git a/web/src/feed/events.ts b/web/src/feed/events.ts index 19535c3..14a42d0 100644 --- a/web/src/feed/events.ts +++ b/web/src/feed/events.ts @@ -23,11 +23,12 @@ export const KINDS: { value: ChangeKind; label: string }[] = [ ]; export const DEFAULT_KINDS: ChangeKind[] = ["status", "description", "dates", "comment"]; -// виды из адреса: неизвестные отбрасываются, пусто — набор по умолчанию +// виды из адреса: неизвестные отбрасываются; пусто или ни одного известного — набор по умолчанию (M-5); «none» — пустой набор (R-2) export function parseKinds(v: string | null): ChangeKind[] { - if (!v) return [...DEFAULT_KINDS]; - const asked = v.split(",").map((s) => s.trim()); - return KINDS.map((k) => k.value).filter((k) => asked.includes(k)); + if (v === "none") return []; + const asked = (v ?? "").split(",").map((s) => s.trim()); + const known = KINDS.map((k) => k.value).filter((k) => asked.includes(k)); + return known.length ? known : [...DEFAULT_KINDS]; } // в адрес — в порядке KINDS; набор по умолчанию не пишется @@ -68,7 +69,10 @@ export function eventText(e: ChangeEvent): string { case "status": return `статус «${dash(e.from)}» → «${dash(e.to)}»`; case "description": return "описание изменено"; case "dates": return `${DATE_LABELS[e.field] ?? e.field} ${day(e.from_value, e.from)} → ${day(e.to_value, e.to)}`; - case "comment": return "комментарий: " + cut(oneLine(e.to), 300); + case "comment": { // первые 300 символов текста и «…» (M-4); без текста — «—» (M-6) + const s = oneLine(e.to); + return "комментарий: " + (!s ? "—" : s.length <= 300 ? s : s.slice(0, 300) + "…"); + } default: { const v = (s: string | null) => (s ? cut(oneLine(s), 80) : "—"); return `${FIELD_LABELS[e.field] ?? e.field}: «${v(e.from)}» → «${v(e.to)}»`; diff --git a/web/src/shell/Layout.tsx b/web/src/shell/Layout.tsx index d262f35..2c05957 100644 --- a/web/src/shell/Layout.tsx +++ b/web/src/shell/Layout.tsx @@ -60,7 +60,7 @@ export function Layout() { : resolveProject(urlLabel, rememberedProject(), projects.data), [projects.isPending, projects.error, projects.data, urlLabel]); const okLabel = state.kind === "ok" ? state.project.label : null; const fromUrl = state.kind === "ok" && state.fromUrl; - const onPage = at === "/board" || at === "/issues"; + const onPage = at === "/board" || at === "/issues" || at === "/feed"; useEffect(() => { if (okLabel) rememberProject(okLabel); }, [okLabel]); // выбранный проект записывается в адрес без новой записи истории; с открытой карточкой адрес не трогаем (design 10) useEffect(() => { diff --git a/web/src/shell/NotFound.tsx b/web/src/shell/NotFound.tsx index b7805c4..1d17bde 100644 --- a/web/src/shell/NotFound.tsx +++ b/web/src/shell/NotFound.tsx @@ -5,7 +5,7 @@ export function NotFound() { return (
    Страница не найдена}> - Разделы приложения: Доска связей, Задачи. + Разделы приложения: Доска связей, Задачи, Лента.
    ); diff --git a/web/src/shell/Sidebar.test.tsx b/web/src/shell/Sidebar.test.tsx index b7cac14..6d09eed 100644 --- a/web/src/shell/Sidebar.test.tsx +++ b/web/src/shell/Sidebar.test.tsx @@ -72,6 +72,7 @@ describe("боковая панель", () => { const main = within(screen.getByRole("main")); expect(main.getByRole("link", { name: "Доска связей" })).toHaveAttribute("href", "/app/board"); expect(main.getByRole("link", { name: "Задачи" })).toHaveAttribute("href", "/app/issues"); + expect(main.getByRole("link", { name: "Лента" })).toHaveAttribute("href", "/app/feed"); // M-2 expect(screen.getByRole("alert")).toHaveTextContent("Страница не найдена"); });