sdlc-hub/web/src/feed/events.ts
Andrey Epifancev e0d8eee5b4 Лента: follow-up ревью M-1…M-7, README по журналу изменений (#77, #76)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 12:09:52 +04:00

96 lines
5.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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"];
// виды из адреса: неизвестные отбрасываются; пусто или ни одного известного — набор по умолчанию (M-5); «none» — пустой набор (R-2)
export function parseKinds(v: string | null): ChangeKind[] {
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; набор по умолчанию не пишется
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": { // первые 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)}»`;
}
}
}
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()];
}