diff --git a/web/src/board/constants.ts b/web/src/board/constants.ts new file mode 100644 index 0000000..9097bad --- /dev/null +++ b/web/src/board/constants.ts @@ -0,0 +1,14 @@ +export const JIRA_URL = "https://sdlc.go.rshbank.ru/jira"; +// ключ эпика → название дорожки; строки типа «Фича» в файле дополняют список +export const EPICS: Record = {"AURORA-1350": "Архитектура и инфраструктура", "AURORA-1351": "Справочники ГК / ГСК / ГСЗ / ЮЛ", "AURORA-1352": "Консолидация: карточка, версии, статусы", "AURORA-1353": "Загрузка документов и распознавание", "AURORA-1354": "Сервисы распознавания и парсинга", "AURORA-1355": "Логические проверки отчётности", "AURORA-1356": "Сводная ОСВ", "AURORA-1357": "Сопоставление ВГО и Шахматка", "AURORA-1358": "Расхождения и корректировки", "AURORA-1359": "Гудвилл", "AURORA-1360": "Формирование КФО", "AURORA-1361": "Экспорт", "AURORA-1362": "Журнал аудита", "AURORA-1363": "Единое хранилище (ЕХФД)", "AURORA-1364": "Аналитика", "AURORA-1529": "Роли, доступ и аутентификация (SSO)", "AURORA-1530": "Приёмка и передача системы (тестирование КА, ПСИ)"}; +export const KIND: Record = {"решение": "decision", "аналитика": "analytics", "актуализация": "analytics", "разработка": "dev", "UI": "ui", "тестирование": "test", "организационная": "org"}; // вид работы → цвет и фильтр легенды +export const PRIO: Record = {"Критичный": "crit", "Наивысший": "crit", "Высокий": "high", "Средний": "mid", "Низкий": "low", "Самый низкий": "low"}; +export const RELATED = "связанные"; // единственный ненаправленный тип связи +export const NO_EPIC = "Без эпика", NOT_IN_FILE = "Нет в файле", NOT_IN_HUB = "Нет в зеркале"; // служебные дорожки, всегда внизу +export const STUB_TEXT: Record = { [NOT_IN_FILE]: "нет в файле", [NOT_IN_HUB]: "нет в зеркале" }; // подпись заглушки на служебной дорожке +export const KEY_RE = /^[A-Z][A-Z0-9]+-\d+$/; +// геометрия доски, px: карточка, колонка слоя, отступы, свёрнутая дорожка, сетка и порог примагничивания +export const CW = 240, COLW = 296, LEFT = 24, TOP = 40, MAXROWS = 4, FOLD_H = 36, GRID = 8, SNAP = 6, MINW = 160, MINH = 80, TITLE_LH = 19; +// в режиме «по датам» карточка не выходит за свою колонку даты: x и ширина зажимаются по границам колонки +export const CPAD = 6; +export const EDGE_R = 10; diff --git a/web/src/board/dicts.test.ts b/web/src/board/dicts.test.ts new file mode 100644 index 0000000..e6f1987 --- /dev/null +++ b/web/src/board/dicts.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; +import golden from "./__fixtures__/hub-basic.expected.json"; +import settings from "./__fixtures__/settings.json"; +import { dictsFromSettings } from "./dicts.ts"; + +describe("dictsFromSettings", () => { + it("обращает словари настроек и совпадает со словарями старой страницы", () => { + const { dicts, missing } = dictsFromSettings(settings); + expect(dicts).toEqual(golden.dicts); + expect(missing).toEqual([]); + }); + it("называет пустые и отсутствующие словари", () => { + expect(dictsFromSettings({ kind_labels: {} }).missing).toEqual(["kind_labels", "link_types"]); + }); +}); diff --git a/web/src/board/dicts.ts b/web/src/board/dicts.ts new file mode 100644 index 0000000..510ae94 --- /dev/null +++ b/web/src/board/dicts.ts @@ -0,0 +1,13 @@ +import type { HubSettings } from "../api/types.ts"; +import type { Dicts } from "./types.ts"; + +// словари доски из настроек хаба: kind_labels (вид → метка) и link_types (логический тип → имя Jira) обращаются +// в «метка → вид» и «имя Jira → логический тип»; пустой словарь — в missing (доска сообщит, чего не хватает) +const invert = (o: Record | undefined) => Object.fromEntries(Object.entries(o ?? {}).map(([k, v]) => [v, k])); + +export function dictsFromSettings(s: HubSettings): { dicts: Dicts; missing: string[] } { + const dicts = { kindLabels: invert(s.kind_labels), linkTypes: invert(s.link_types) }; + const missing = [["kind_labels", dicts.kindLabels], ["link_types", dicts.linkTypes]] + .filter(([, d]) => Object.keys(d as object).length === 0).map(([n]) => n as string); + return { dicts, missing }; +} diff --git a/web/src/board/model/csv.ts b/web/src/board/model/csv.ts new file mode 100644 index 0000000..9f09714 --- /dev/null +++ b/web/src/board/model/csv.ts @@ -0,0 +1,15 @@ +// разбор CSV: BOM, кавычки с удвоением, CRLF/LF/CR; пустые строки отбрасываются +export function parseCsv(text: string): string[][] { + const rows: string[][] = []; let row: string[] = [], cell = "", q = false; + if (text.charCodeAt(0) === 0xFEFF) text = text.slice(1); + for (let i = 0; i < text.length; i++) { + const ch = text[i]; + if (q) { if (ch === '"') { if (text[i + 1] === '"') { cell += '"'; i++; } else q = false; } else cell += ch; continue; } + if (ch === '"') q = true; + else if (ch === ",") { row.push(cell); cell = ""; } + else if (ch === "\n" || ch === "\r") { if (ch === "\r" && text[i + 1] === "\n") i++; row.push(cell); rows.push(row); row = []; cell = ""; } + else cell += ch; + } + if (cell !== "" || row.length) { row.push(cell); rows.push(row); } + return rows.filter((r) => r.some((c) => c !== "")); +} diff --git a/web/src/board/model/fromCsv.ts b/web/src/board/model/fromCsv.ts new file mode 100644 index 0000000..2818a56 --- /dev/null +++ b/web/src/board/model/fromCsv.ts @@ -0,0 +1,51 @@ +import { EPICS, KEY_RE, NO_EPIC } from "../constants.ts"; +import type { BoardModel, BoardNode, Dicts, Link } from "../types.ts"; +import { parseCsv } from "./csv.ts"; +import { addLink, addStubs } from "./links.ts"; +import { sortableDate } from "./util.ts"; + +// CSV из фильтра Jira: нужны колонки Ключ проблемы, Тема, Статус, Приоритет, Тип задачи, Метки, Связи Фичи, Плановая дата окончания, Исполнитель, Спринт +// и «Внешняя/Внутренняя ссылка на проблему (ASUPR_…)». У задачи X «Внешняя ссылка (ASUPR_Блокер_Начала)» содержит Y, если X блокирует Y +// (outward-описание «Блокирует начало выполнения»); «Внутренняя ссылка» — если Y блокирует X. До 11.10.2026 связи в Jira стояли +// задом наперёд (ошибка направления в jira_sync.py), и страница читала «Внешняя» как блокеров; 11.10.2026 связи пересозданы, чтение исправлено. +export function fromCsv(text: string, fileName: string, dicts: Dicts): BoardModel { + const rows = parseCsv(text); + if (rows.length < 2) throw new Error("в файле нет строк с задачами"); + const head = rows[0].map((h) => h.trim()); + const find = (...subs: string[]) => head.findIndex((h) => subs.some((s) => h.toLowerCase().includes(s.toLowerCase()))); + const cols = { + key: find("Ключ проблемы", "Ключ задачи", "Issue key", "Ключ"), sum: find("Тема", "Резюме", "Summary"), status: find("Статус", "Status"), + prio: find("Приоритет", "Priority"), type: find("Тип задачи", "Тип запроса", "Issue Type"), epic: find("Связи Фичи"), + due: find("Плановая дата окончания", "Срок исполнения", "Due date"), who: find("Исполнитель", "Assignee"), + }; + const labelCols = head.map((h, i) => (/^(Метки|Labels)$/i.test(h) ? i : -1)).filter((i) => i >= 0); + const sprintCols = head.map((h, i) => (/^(Спринт|Sprint)$/i.test(h) ? i : -1)).filter((i) => i >= 0); + if (cols.key < 0 || cols.sum < 0) throw new Error("не нашёл колонки «Ключ проблемы» и «Тема»; в файле: " + head.slice(0, 10).join(", ") + "…"); + const linkCols: { i: number; type: string; out: boolean }[] = []; + head.forEach((h, i) => { for (const [t, type] of Object.entries(dicts.linkTypes)) if (h.includes(t)) linkCols.push({ i, type, out: /outward|исход|внешн/.test(h.toLowerCase()) }); }); + const problems: string[] = []; + if (!linkCols.length) problems.push("в файле нет колонок связей — в фильтре Jira добавьте колонку «Связанные задачи» или экспортируйте «все поля»"); + if (!sprintCols.length) problems.push("в файле нет колонки «Спринт» — выбор спринта недоступен, все задачи считаются выбранными"); + if (cols.epic < 0) problems.push("нет колонки «Связи Фичи» — дорожки по эпикам не построить"); + const cell = (r: string[], i: number) => (i >= 0 ? r[i] || "" : "").trim(); + const isEpic = (r: string[]) => cols.type >= 0 && /^(Фича|Epic|Эпик)$/i.test(cell(r, cols.type)); + const epicNames: Record = { ...EPICS }; + rows.slice(1).forEach((r) => { const k = cell(r, cols.key); if (KEY_RE.test(k) && isEpic(r)) epicNames[k] = cell(r, cols.sum) || k; }); + const nodes: BoardNode[] = [], links: Link[] = [], seen = new Set(), sprints: Record = {}; + for (const r of rows.slice(1)) { + const key = cell(r, cols.key); if (!KEY_RE.test(key) || isEpic(r)) continue; + let kind = cell(r, cols.type); + for (const c of labelCols) { const v = cell(r, c); if (dicts.kindLabels[v]) kind = dicts.kindLabels[v]; } + const epicKey = cell(r, cols.epic); + const sprint = sprintCols.map((c) => cell(r, c)).filter(Boolean).pop() || ""; + if (sprint) sprints[sprint] = (sprints[sprint] || 0) + 1; + nodes.push({ key, title: cell(r, cols.sum), kind, prio: cell(r, cols.prio), due: sortableDate(cell(r, cols.due)), status: cell(r, cols.status), + epic: epicKey ? (epicNames[epicKey] || epicKey) : NO_EPIC, sprint, who: cell(r, cols.who), stub: false }); + for (const lc of linkCols) { + const other = cell(r, lc.i); if (!KEY_RE.test(other)) continue; + addLink(links, seen, lc.out ? key : other, lc.out ? other : key, lc.type); + } + } + const missing = addStubs(nodes, links); + return { nodes, links, sprints, fileName, problems, hasSprint: sprintCols.length > 0, missing }; +} diff --git a/web/src/board/model/fromHub.ts b/web/src/board/model/fromHub.ts new file mode 100644 index 0000000..9c1b442 --- /dev/null +++ b/web/src/board/model/fromHub.ts @@ -0,0 +1,30 @@ +import type { BoardIssue, BoardSnapshot } from "../../api/types.ts"; +import { EPICS, KEY_RE, NO_EPIC, NOT_IN_HUB } from "../constants.ts"; +import type { BoardModel, BoardNode, Dicts, Link } from "../types.ts"; +import { addLink, addStubs } from "./links.ts"; +import { sortableDate } from "./util.ts"; + +// снимок хаба (GET /board) → модель доски. Эпики («Фича»/Epic) — имена дорожек, не карточки; соседи по связям — обычные карточки; +// вид работы — первая метка kind-*, иначе тип задачи; срок — плановая дата окончания, иначе срок исполнения; связи — только известных +// типов (dicts.linkTypes), между ключами вида AURORA-1234 (ключ идёт в разметку карточки) и не с эпиками; ключи из связей, которых нет в зеркале, — заглушки на дорожке «Нет в зеркале» +export function fromHub(snap: BoardSnapshot, dicts: Dicts): BoardModel { + const isEpic = (it: BoardIssue) => /^(Фича|Epic|Эпик)$/i.test(it.issue_type || ""); + const epicNames: Record = { ...EPICS }, epics = new Set(); + snap.issues.forEach((it) => { if (isEpic(it)) { epics.add(it.key); epicNames[it.key] = it.summary || it.key; } }); + const nodes: BoardNode[] = [], links: Link[] = [], seen = new Set(), sprints: Record = {}; + snap.issues.forEach((it) => { + if (isEpic(it) || !KEY_RE.test(it.key)) return; + const label = (it.labels || []).find((l) => dicts.kindLabels[l]), sprint = it.sprint || ""; + if (sprint) sprints[sprint] = (sprints[sprint] || 0) + 1; + nodes.push({ key: it.key, title: it.summary || "", kind: label ? dicts.kindLabels[label] : (it.issue_type || ""), prio: it.priority || "", + due: sortableDate(it.planned_end || it.duedate || ""), status: it.status || "", + epic: it.epic_key ? (epicNames[it.epic_key] || it.epic_key) : NO_EPIC, sprint, who: it.assignee || "", stub: false }); + }); + snap.links.forEach((l) => { + const type = dicts.linkTypes[l.link_type]; + if (type && KEY_RE.test(l.source) && KEY_RE.test(l.target) && !epics.has(l.source) && !epics.has(l.target)) addLink(links, seen, l.source, l.target, type); + }); + const missing = addStubs(nodes, links, NOT_IN_HUB); + const problems = snap.last_sync ? [] : ["в хабе ещё не было полного обновления из Jira — нажмите «Обновить из Jira»"]; + return { nodes, links, sprints, fileName: "Хаб", problems, hasSprint: true, missing, stubLane: NOT_IN_HUB }; +} diff --git a/web/src/board/model/fromJson.ts b/web/src/board/model/fromJson.ts new file mode 100644 index 0000000..38153ea --- /dev/null +++ b/web/src/board/model/fromJson.ts @@ -0,0 +1,37 @@ +import { CW, KEY_RE, NO_EPIC } from "../constants.ts"; +import type { BoardNode, Dicts, JsonImport, Link, SavedBox } from "../types.ts"; +import { addLink, addStubs } from "./links.ts"; +import { sortableDate } from "./util.ts"; + +const strList = (v: unknown): string[] => Array.isArray(v) ? v.filter((s): s is string => typeof s === "string") : []; + +type Raw = Record; // eslint-disable-line @typescript-eslint/no-explicit-any -- внешний JSON произвольной формы + +// JSON, выгруженный старой страницей (см. exportJson): задачи, связи, раскладка, порядок и свёртка дорожек, фильтры +export function fromJson(text: string, fileName: string, dicts: Dicts): JsonImport { + let d: Raw; try { d = JSON.parse(text); } catch { throw new Error("это не JSON"); } + if (!d || !Array.isArray(d.nodes)) throw new Error("в JSON нет списка nodes — это не выгрузка этой страницы"); + const keys = new Set(), nodes: BoardNode[] = [], links: Link[] = [], seen = new Set(), sprints: Record = {}; + const layout: Record = {}, layoutByDate: Record = {}; + d.nodes.forEach((n: Raw) => { + const key = String(n && n.key || "").trim(); if (!KEY_RE.test(key) || keys.has(key) || n.stub) return; keys.add(key); + const sprint = String(n.sprint || "").trim(); if (sprint) sprints[sprint] = (sprints[sprint] || 0) + 1; + nodes.push({ key, title: String(n.title || ""), kind: String(n.kind || ""), prio: String(n.priority || ""), due: sortableDate(String(n.due || "")), + status: String(n.status || ""), epic: String(n.epic || NO_EPIC), sprint, who: String(n.assignee || ""), stub: false }); + const take = (L: Raw | undefined): SavedBox | null => L && typeof L.x === "number" && typeof L.ry === "number" ? { x: L.x, ry: L.ry, w: typeof L.w === "number" ? L.w : CW, ...(typeof L.h === "number" ? { h: L.h } : {}) } : null; + const L = take(n.layout), LD = take(n.layoutByDate); + if (L) layout[key] = L; + if (LD) layoutByDate[key] = LD; + }); + if (!nodes.length) throw new Error("в nodes нет ни одной задачи с ключом вида AURORA-1234"); + const types = new Set(Object.values(dicts.linkTypes)); + (Array.isArray(d.links) ? d.links : []).forEach((l: Raw) => { + const src = String(l && l.from || "").trim(), dst = String(l && l.to || "").trim(); if (!KEY_RE.test(src) || !KEY_RE.test(dst)) return; + addLink(links, seen, src, dst, types.has(l.type) ? l.type : "блокирует начало"); + }); + const missing = addStubs(nodes, links); + const f: Record = d.filters && typeof d.filters === "object" ? { ...d.filters } : {}; + if (f.mode === "linked") f.neighbors = true; // старые выгрузки: режим «спринт и связанные» = соседи (так в эталоне старой страницы) + return { model: { nodes, links, sprints, fileName: String(d.source || fileName), problems: [], hasSprint: nodes.some((n) => n.sprint), missing }, + layout, layoutByDate, dateColumns: d.dateColumns && typeof d.dateColumns === "object" ? d.dateColumns : null, laneOrder: strList(d.laneOrder), collapsedLanes: Array.isArray(d.collapsedLanes) ? strList(d.collapsedLanes) : null, filters: f }; +} diff --git a/web/src/board/model/links.ts b/web/src/board/model/links.ts new file mode 100644 index 0000000..a849fd7 --- /dev/null +++ b/web/src/board/model/links.ts @@ -0,0 +1,18 @@ +import { NOT_IN_FILE, RELATED, STUB_TEXT } from "../constants.ts"; +import type { BoardNode, Link } from "../types.ts"; + +export const stubNode = (key: string, lane: string): BoardNode => + ({ key, title: STUB_TEXT[lane], kind: "", prio: "", due: "", status: "", epic: lane, sprint: "", stub: true, who: "" }); + +// связи без дублей; для «связанных» порядок концов не важен +export function addLink(links: Link[], seen: Set, src: string, dst: string, type: string): void { + const id = type === RELATED ? [src, dst].sort().join("|") + type : src + ">" + dst + type; + if (seen.has(id)) return; seen.add(id); links.push({ src, dst, type }); +} +// задачи, упомянутые в связях, но отсутствующие в источнике, добавляются заглушками на служебную дорожку lane +export function addStubs(nodes: BoardNode[], links: Link[], lane: string = NOT_IN_FILE): number { + const keys = new Set(nodes.map((n) => n.key)), missing = new Set(); + links.forEach((l) => [l.src, l.dst].forEach((k) => { if (!keys.has(k)) missing.add(k); })); + missing.forEach((k) => nodes.push(stubNode(k, lane))); + return missing.size; +} diff --git a/web/src/board/model/model.test.ts b/web/src/board/model/model.test.ts new file mode 100644 index 0000000..0842028 --- /dev/null +++ b/web/src/board/model/model.test.ts @@ -0,0 +1,45 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import type { BoardSnapshot } from "../../api/types.ts"; +import { canon } from "../../test/canon.ts"; +import { dictsFromSettings } from "../dicts.ts"; +import settings from "../__fixtures__/settings.json"; +import { parseCsv } from "./csv.ts"; +import { fromCsv } from "./fromCsv.ts"; +import { fromHub } from "./fromHub.ts"; +import { fromJson } from "./fromJson.ts"; + +const fx = (name: string) => readFileSync(join(import.meta.dirname, "..", "__fixtures__", name), "utf8"); +const golden = (name: string) => JSON.parse(fx(name)); +const { dicts } = dictsFromSettings(settings); + +describe("модель доски = links.html@3931a88", () => { + it("fromHub — снимок с эпиками, соседями, заглушками", () => { + const snap = JSON.parse(fx("hub-basic.board.json")) as BoardSnapshot; + expect(canon(fromHub(snap, dicts))).toEqual(golden("hub-basic.expected.json").model); + }); + it("fromHub — пустое зеркало без полного прогона", () => { + const snap = JSON.parse(fx("hub-empty.board.json")) as BoardSnapshot; + expect(canon(fromHub(snap, dicts))).toEqual(golden("hub-empty.expected.json").model); + }); + it("parseCsv и fromCsv — BOM, CRLF, кавычки, повторные колонки, даты разных локалей", () => { + const text = fx("jira-export.csv"); + const g = golden("jira-export.expected.json"); + expect(parseCsv(text)).toEqual(g.rows); + expect(canon(fromCsv(text, "jira-export.csv", dicts))).toEqual(g.model); + }); + it("fromCsv без колонок связей, спринта и эпика — проблемы файла", () => { + expect(canon(fromCsv(fx("jira-no-links.csv"), "jira-no-links.csv", dicts))).toEqual(golden("jira-no-links.expected.json").model); + }); + it("fromCsv без ключа и темы — та же ошибка", () => { + expect(() => fromCsv(fx("jira-bad.csv"), "jira-bad.csv", dicts)).toThrow(golden("jira-bad.expected.json").error); + }); + it("fromJson — выгрузка старой страницы", () => { + expect(canon(fromJson(fx("old-board.json"), "old-board.json", dicts))).toEqual(golden("old-board.expected.json").import); + }); + it("fromJson — не JSON и JSON без nodes", () => { + expect(() => fromJson("{", "x.json", dicts)).toThrow("это не JSON"); + expect(() => fromJson("{}", "x.json", dicts)).toThrow("в JSON нет списка nodes"); + }); +}); diff --git a/web/src/board/model/util.test.ts b/web/src/board/model/util.test.ts new file mode 100644 index 0000000..fb88ec6 --- /dev/null +++ b/web/src/board/model/util.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { dayDiff, fmtDate, plural, shortName, sortableDate } from "./util.ts"; + +describe("util", () => { + it("sortableDate приводит даты Jira к ГГГГ-ММ-ДД", () => { + expect(sortableDate("2026-10-20T10:00:00")).toBe("2026-10-20"); + expect(sortableDate("5.1.2026")).toBe("2026-01-05"); + expect(sortableDate("20/окт/26")).toBe("2026-10-20"); + expect(sortableDate("10/Oct/2026")).toBe("2026-10-10"); + expect(sortableDate("потом")).toBe("потом"); + }); + it("shortName, plural, fmtDate, dayDiff", () => { + expect(shortName("Степанов Максим Александрович")).toBe("Степанов М. А."); + expect(shortName("Stepanov-MAl")).toBe("Stepanov-MAl"); + expect(shortName("")).toBe("не назначен"); + expect(plural(21, "задача", "задачи", "задач")).toBe("21 задача"); + expect(plural(12, "задача", "задачи", "задач")).toBe("12 задач"); + expect(fmtDate("2026-10-20")).toBe("20.10"); + expect(dayDiff("2026-10-14", "2026-10-20")).toBe(6); + expect(dayDiff("", "2026-10-20")).toBeNull(); + }); +}); diff --git a/web/src/board/model/util.ts b/web/src/board/model/util.ts new file mode 100644 index 0000000..669dc4e --- /dev/null +++ b/web/src/board/model/util.ts @@ -0,0 +1,39 @@ +import type { BoardModel } from "../types.ts"; + +export function plural(n: number, one: string, few: string, many: string): string { + return `${n} ${n % 10 === 1 && n % 100 !== 11 ? one : n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 10 || n % 100 >= 20) ? few : many}`; +} +export function fmtDate(d: string): string { + const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(d); + return m ? `${m[3]}.${m[2]}` : d; +} +export function dayDiff(a: string, b: string): number | null { + return (!a || !b) ? null : Math.round((new Date(b).getTime() - new Date(a).getTime()) / 86400000); +} +export const notStarted = (status: string): boolean => /ожида|backlog|бэклог|к выполнению|to do|open/i.test(status || ""); +export const unassigned = (w: string): boolean => !w || /^(не назначен|unassigned)$/i.test(w); +export const whoKey = (w: string): string => unassigned(w) ? "" : w.trim(); +// «Степанов Максим Александрович» → «Степанов М. А.»; логины вида Stepanov-MAl остаются как есть +export function shortName(w: string): string { + if (unassigned(w)) return "не назначен"; + const parts = w.trim().split(/\s+/); if (parts.length < 2) return w; + return parts[0] + " " + parts.slice(1, 3).map((x) => x[0].toUpperCase() + ".").join(" "); +} +const MONTHS: Record = { jan: "01", feb: "02", mar: "03", apr: "04", may: "05", jun: "06", jul: "07", aug: "08", sep: "09", oct: "10", nov: "11", dec: "12", янв: "01", фев: "02", мар: "03", апр: "04", май: "05", мая: "05", июн: "06", июл: "07", авг: "08", сен: "09", окт: "10", ноя: "11", дек: "12" }; +// даты Jira в разных локалях → ГГГГ-ММ-ДД, чтобы сравнивать строками +export function sortableDate(d: string): string { + let m = /^(\d{4})-(\d{2})-(\d{2})/.exec(d); if (m) return d.slice(0, 10); + m = /^(\d{1,2})[./](\d{1,2})[./](\d{4})/.exec(d); if (m) return `${m[3]}-${m[2].padStart(2, "0")}-${m[1].padStart(2, "0")}`; + m = /^(\d{1,2})\/([A-Za-zА-Яа-я]{3})\/(\d{2})(\d{2})?/.exec(d); + if (m) { + const mon = MONTHS[m[2].toLowerCase()]; + if (mon) return `${m[4] ? m[3] + m[4] : "20" + m[3]}-${mon}-${m[1].padStart(2, "0")}`; + } + return d; +} +export const fmtStamp = (iso: string): string => { + const d = new Date(iso); + return isNaN(d.getTime()) ? String(iso) : d.toLocaleString("ru-RU", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" }).replace(",", ""); +}; +export const emptyModel = (text: string): BoardModel => + ({ nodes: [], links: [], sprints: {}, fileName: "", problems: [], hasSprint: false, missing: 0, emptyText: text }); diff --git a/web/src/board/types.ts b/web/src/board/types.ts new file mode 100644 index 0000000..a4014a6 --- /dev/null +++ b/web/src/board/types.ts @@ -0,0 +1,36 @@ +// модель доски связей; поля и их имена — как у links.html@3931a88 (условие golden-сравнения) +export interface BoardNode { + key: string; title: string; kind: string; prio: string; due: string; status: string; epic: string; sprint: string; + who: string; stub: boolean; +} +export interface Link { src: string; dst: string; type: string } +export interface BoardModel { + nodes: BoardNode[]; links: Link[]; sprints: Record; fileName: string; problems: string[]; + hasSprint: boolean; missing: number; stubLane?: string; emptyText?: string; +} +// kindLabels: метка Jira → вид работы; linkTypes: имя типа связи Jira → логический тип (обращённые словари /settings) +export interface Dicts { kindLabels: Record; linkTypes: Record } + +export interface Filters { + neighbors: boolean; onlyLinked: boolean; onlyHand: boolean; crit: boolean; byDate: boolean; + sprints: Set; who: Set; hideKind: Set; hideEdge: Set; +} +export const noFilters = (): Filters => ({ + neighbors: false, onlyLinked: false, onlyHand: false, crit: false, byDate: false, + sprints: new Set(), who: new Set(), hideKind: new Set(), hideEdge: new Set(), +}); + +export interface VisNode extends BoardNode { dim: boolean; focus: boolean; crit: boolean } +export interface VisLink extends Link { hand: boolean; crit: boolean } +export interface ProblemItem { a: VisNode; b: VisNode; text: string } +export interface Problems { dates: ProblemItem[]; unassigned: ProblemItem[]; late: ProblemItem[]; hand: ProblemItem[] } +export interface Visible { + nodes: VisNode[]; links: VisLink[]; lanes: { name: string; keys: string[] }[]; + removed: { core: number; ctx: number; edge: number; loose: number }; problems: Problems; +} +export interface SavedBox { x: number; ry: number; w: number; h?: number } +export interface JsonImport { + model: BoardModel; layout: Record; layoutByDate: Record; + dateColumns: Record | null; laneOrder: string[]; collapsedLanes: string[] | null; + filters: Record; +}