Лента: события, период и запрос /changes (#77)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
47a156733f
commit
6f5fae7d82
@ -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<ChangesResponse>("/changes", { ...q, limit: CHANGES_LIMIT }) });
|
||||
|
||||
export const useSettings = () =>
|
||||
useQuery({ queryKey: qk.settings, queryFn: () => api.get<HubSettings>("/settings"), staleTime: Infinity });
|
||||
|
||||
|
||||
@ -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";
|
||||
|
||||
79
web/src/feed/events.test.ts
Normal file
79
web/src/feed/events.test.ts
Normal file
@ -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> = {}): 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]]);
|
||||
});
|
||||
});
|
||||
91
web/src/feed/events.ts
Normal file
91
web/src/feed/events.ts
Normal file
@ -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<string, string> = {
|
||||
assignee: "исполнитель", Sprint: "спринт", labels: "метки", priority: "приоритет", summary: "тема", epic_link: "эпик",
|
||||
Link: "связь", Attachment: "вложение", resolution: "резолюция",
|
||||
};
|
||||
const DATE_LABELS: Record<string, string> = { 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<string, IssueGroup>();
|
||||
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()];
|
||||
}
|
||||
@ -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(() => {
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user