96 lines
5.5 KiB
TypeScript
96 lines
5.5 KiB
TypeScript
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()];
|
||
}
|