From 6f5fae7d823f8dcebee99b9bc40cf9712ab36897 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 22 Oct 2026 11:18:06 +0400 Subject: [PATCH] =?UTF-8?q?=D0=9B=D0=B5=D0=BD=D1=82=D0=B0:=20=D1=81=D0=BE?= =?UTF-8?q?=D0=B1=D1=8B=D1=82=D0=B8=D1=8F,=20=D0=BF=D0=B5=D1=80=D0=B8?= =?UTF-8?q?=D0=BE=D0=B4=20=D0=B8=20=D0=B7=D0=B0=D0=BF=D1=80=D0=BE=D1=81=20?= =?UTF-8?q?/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 () => {