From 5f012db8291d4355d046d8906eda7ef1185a9809 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 14 Oct 2026 22:28:20 +0400 Subject: [PATCH] =?UTF-8?q?feat(web):=20=D1=85=D1=80=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D0=BB=D0=B8=D1=89=D0=B5=20=D0=BD=D0=B0=D1=81=D1=82=D1=80=D0=BE?= =?UTF-8?q?=D0=B5=D0=BA=20=D0=B4=D0=BE=D1=81=D0=BA=D0=B8=20=D1=81=20=D0=BF?= =?UTF-8?q?=D1=80=D0=B5=D0=B6=D0=BD=D0=B8=D0=BC=D0=B8=20=D0=BA=D0=BB=D1=8E?= =?UTF-8?q?=D1=87=D0=B0=D0=BC=D0=B8,=20=D0=B8=D0=BC=D0=BF=D0=BE=D1=80?= =?UTF-8?q?=D1=82=20=D0=B8=20=D1=8D=D0=BA=D1=81=D0=BF=D0=BE=D1=80=D1=82=20?= =?UTF-8?q?JSON=20(#21)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- web/src/board/exportJson.ts | 34 ++++++++ web/src/board/model/fromJson.ts | 3 +- web/src/board/storage.test.ts | 80 ++++++++++++++++++ web/src/board/storage.ts | 142 ++++++++++++++++++++++++++++++++ 4 files changed, 257 insertions(+), 2 deletions(-) create mode 100644 web/src/board/exportJson.ts create mode 100644 web/src/board/storage.test.ts create mode 100644 web/src/board/storage.ts diff --git a/web/src/board/exportJson.ts b/web/src/board/exportJson.ts new file mode 100644 index 0000000..94a9ad3 --- /dev/null +++ b/web/src/board/exportJson.ts @@ -0,0 +1,34 @@ +// полный снимок доски: все задачи и связи, обе раскладки относительно дорожек (layout — обычная, layoutByDate — «по датам»; h только если задана вручную), +// порядок и свёртка дорожек, фильтры. Текущую раскладку компонент сохраняет в хранилище (saveLayout) перед вызовом +import { JIRA_URL } from "./constants.ts"; +import { colWidths, foldedSet, laneOrder, savedLayout } from "./storage.ts"; +import type { BoardModel, Filters, Visible } from "./types.ts"; + +type Saved = Record; + +export function exportJson(model: BoardModel, vis: Visible, f: Filters, now: string): unknown { + const savedPos = (savedLayout(false) || {}) as Saved, savedDate = (savedLayout(true) || {}) as Saved; + const shown = new Set(vis.nodes.map((n) => n.key)); + const fromSaved = (saved: Saved, k: string) => { + const r = saved[k]; if (!r) return null; + const o: { x: number; ry: number; w: number; h?: number } = { x: r.x, ry: r.ry, w: r.w }; + if (typeof r.h === "number") o.h = r.h; + return o; + }; + const nodes = model.nodes.filter((n) => !n.stub).map((n) => ({ + key: n.key, title: n.title, status: n.status, priority: n.prio, kind: n.kind, due: n.due || null, assignee: n.who || null, sprint: n.sprint || null, epic: n.epic, + url: `${JIRA_URL}/browse/${n.key}`, visible: shown.has(n.key), layout: fromSaved(savedPos, n.key), layoutByDate: fromSaved(savedDate, n.key), + })); + const order = laneOrder(); + return { + format: "arm-ka-links/1", exportedAt: now, source: model.fileName, + filters: { sprints: [...f.sprints], assignees: [...f.who], neighbors: f.neighbors, onlyLinked: f.onlyLinked, onlyHand: f.onlyHand, criticalPath: f.crit, byDate: f.byDate, + hiddenKinds: [...f.hideKind], hiddenEdges: [...f.hideEdge] }, + laneOrder: order.length ? order : vis.lanes.map((l) => l.name), collapsedLanes: [...foldedSet()], dateColumns: colWidths(), + nodes, links: model.links.map((l) => ({ from: l.src, to: l.dst, type: l.type })), + }; +} +// имя файла выгрузки: links-<имя исходного файла без .csv>-<дата>.json +export function exportFileName(model: BoardModel, now: string): string { + return `links-${(model.fileName || "board").replace(/[^\wА-Яа-яЁё.-]+/g, "_").replace(/\.csv$/i, "")}-${now.slice(0, 10)}.json`; +} diff --git a/web/src/board/model/fromJson.ts b/web/src/board/model/fromJson.ts index 38153ea..587565a 100644 --- a/web/src/board/model/fromJson.ts +++ b/web/src/board/model/fromJson.ts @@ -1,10 +1,9 @@ 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 { strList } from "../storage.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): задачи, связи, раскладка, порядок и свёртка дорожек, фильтры diff --git a/web/src/board/storage.test.ts b/web/src/board/storage.test.ts new file mode 100644 index 0000000..ac258d8 --- /dev/null +++ b/web/src/board/storage.test.ts @@ -0,0 +1,80 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { BoardSnapshot } from "../api/types.ts"; +import { canon } from "../test/canon.ts"; +import { computeVisible } from "./analysis/visible.ts"; +import { dictsFromSettings } from "./dicts.ts"; +import { exportJson } from "./exportJson.ts"; +import settings from "./__fixtures__/settings.json"; +import storageCases from "./__fixtures__/storage.cases.json"; +import { computeLayout, measuredHeight } from "./layout/layout.ts"; +import { fromHub } from "./model/fromHub.ts"; +import { fromJson } from "./model/fromJson.ts"; +import { applyJsonSettings, migrateMode, migrateStore, readPrefs, saveLayout, savedLayout, setColWidth, writeFlag } from "./storage.ts"; +import { noFilters } from "./types.ts"; + +const fx = (name: string) => readFileSync(join(import.meta.dirname, "__fixtures__", name), "utf8"); +const { dicts } = dictsFromSettings(settings); +const NOW = "2026-10-14T12:00:00.000Z"; +const dump = () => Object.fromEntries(Object.keys(localStorage).map((k) => [k, localStorage.getItem(k)])); + +describe("хранилище = links.html@3931a88", () => { + beforeEach(() => localStorage.clear()); + afterEach(() => vi.restoreAllMocks()); + + const expected = JSON.parse(fx("storage.expected.json")); + for (const c of storageCases as unknown as { name: string; op: string; before: Record }[]) { + it(`миграция: ${c.name}`, () => { + Object.entries(c.before).forEach(([k, v]) => localStorage.setItem(k, v)); + if (c.op === "startup") migrateMode(); + migrateStore(); + expect(dump()).toEqual(expected[c.name]); + }); + } + + it("импорт JSON старой страницы: хранилище и фильтры", () => { + const g = JSON.parse(fx("old-board.expected.json")); + const patch = applyJsonSettings(fromJson(fx("old-board.json"), "old-board.json", dicts)); + expect(dump()).toEqual(g.storage); + const f = { ...noFilters(), ...patch }; + expect(canon({ neighbors: f.neighbors, onlyLinked: f.onlyLinked, onlyHand: f.onlyHand, crit: f.crit, + byDate: f.byDate, hideKind: f.hideKind, hideEdge: f.hideEdge })).toEqual(g.S); + }); + + it("экспорт JSON после раскладки по умолчанию", () => { + const g = JSON.parse(fx("hub-basic.expected.json")); + const model = fromHub(JSON.parse(fx("hub-basic.board.json")) as BoardSnapshot, dicts); + const vis = computeVisible(model, noFilters(), [], "2026-10-14"); + const lay = computeLayout(vis, { byDate: false, colWidths: {}, saved: null, today: "2026-10-14", + measure: () => ({ h: measuredHeight(117), other: 79 }) }); + saveLayout(lay.rel, lay.colBounds, false); + expect(canon(exportJson(model, vis, noFilters(), NOW))).toEqual(g.export); + }); + + it("флаги и ширины колонок читаются обратно", () => { + writeFlag("neighbors", true); + writeFlag("snap", false); + expect(readPrefs().filters.neighbors).toBe(true); + expect(readPrefs().snap).toBe(false); + setColWidth("2026-10-20", 400); + expect(JSON.parse(localStorage.getItem("graph-colw:board")!)).toEqual({ "2026-10-20": 400 }); + setColWidth("2026-10-20", null); + expect(JSON.parse(localStorage.getItem("graph-colw:board")!)).toEqual({}); + }); + + it("закрытое хранилище: чтение даёт умолчания, запись молча пропускается", () => { + vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => { throw new DOMException("closed", "SecurityError"); }); + vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { throw new DOMException("closed", "SecurityError"); }); + vi.spyOn(Storage.prototype, "key").mockImplementation(() => { throw new DOMException("closed", "SecurityError"); }); + expect(() => { migrateMode(); migrateStore(); writeFlag("crit", true); }).not.toThrow(); + expect(readPrefs().snap).toBe(true); + }); + + it("раскладка неверной формы отбрасывается при чтении", () => { + localStorage.setItem("graph-pos:board", JSON.stringify({ "AURORA-1": null, "AURORA-2": { x: "1" }, "AURORA-3": { x: 1, ry: 2, w: 240, h: 90 } })); + expect(savedLayout(false)).toEqual({ "AURORA-3": { x: 1, ry: 2, w: 240, h: 90 } }); + localStorage.setItem("graph-pos:board", "[1]"); + expect(savedLayout(false)).toBeNull(); + }); +}); diff --git a/web/src/board/storage.ts b/web/src/board/storage.ts new file mode 100644 index 0000000..d2885d3 --- /dev/null +++ b/web/src/board/storage.ts @@ -0,0 +1,142 @@ +// хранилище настроек доски: ключи и формат — как у links.html@3931a88 (прежние настройки пользователя продолжают работать) +// localStorage: общие настройки «graph-<имя>», настройки доски «graph-<имя>:board» (раскладка, дорожки, фильтры). Ключ не зависит от имени +// файла: карточки адресуются ключами Jira, поэтому расстановка переживает замену данных. Любое обращение защищено от недоступного хранилища +import { COLW, GRID } from "./constants.ts"; +import type { RelBox, Layout } from "./layout/layout.ts"; +import type { Filters, JsonImport } from "./types.ts"; +import { noFilters } from "./types.ts"; + +export const store = { + get(k: string, d: string | null): string | null { try { const v = localStorage.getItem(k); return v === null ? d : v; } catch { return d; } }, + set(k: string, v: string): void { try { localStorage.setItem(k, v); } catch { /* хранилище недоступно */ } }, + del(k: string): void { try { localStorage.removeItem(k); } catch { /* хранилище недоступно */ } }, + json(k: string, d: T): T { try { const v = JSON.parse(store.get(k, "null") as string); return v === null ? d : v as T; } catch { return d; } }, +}; +export const pref = (name: string): string => "graph-" + name; +const BOARD = "board", BOARD_KEYS = ["pos", "lanes", "fold", "sprint", "who"]; +export const fileKey = (name: string): string => "graph-" + name + ":" + BOARD; +export const strList = (v: unknown): string[] => Array.isArray(v) ? v.filter((s): s is string => typeof s === "string") : []; +const isObj = (v: unknown): v is Record => !!v && typeof v === "object" && !Array.isArray(v); +const num = (v: unknown): boolean => typeof v === "number" && Number.isFinite(v); + +// до 11.10.2026 настройки лежали под именем файла («graph-pos:<файл>») и терялись при каждой новой выгрузке; при первом запуске новой версии +// переносим на общий ключ набор того файла, где расставлено больше всего карточек +export function migrateStore(): void { + if (store.get(fileKey("pos"), null) !== null) return; + const names: string[] = []; + try { + for (let i = 0; i < localStorage.length; i++) { + const k = localStorage.key(i); + if (k && k.startsWith("graph-pos:") && !k.endsWith(":" + BOARD)) names.push(k.slice("graph-pos:".length)); + } + } catch { return; } + if (!names.length) return; + const size = (n: string): number => Object.keys(store.json("graph-pos:" + n, {}) || {}).length; + names.sort((a, b) => size(b) - size(a)); + BOARD_KEYS.forEach((k) => { const v = store.get("graph-" + k + ":" + names[0], null); if (v !== null) store.set(fileKey(k), v); }); +} +// до 11.10.2026 был режим показа: «linked» стал флагом «соседи», «all» — пустым выбором спринта +export function migrateMode(): void { + const old = store.get(pref("mode"), null); + if (old === null) return; + if (old === "linked") store.set(pref("neighbors"), "1"); + if (old === "all") store.set(fileKey("sprint"), "[]"); + store.del(pref("mode")); +} + +export const laneOrder = (): string[] => strList(store.json(fileKey("lanes"), [])); +export const foldedSet = (): Set => new Set(strList(store.json(fileKey("fold"), []))); +export function colWidths(): Record { + const v = store.json(fileKey("colw"), {}); + return isObj(v) ? v as Record : {}; +} + +const layoutKey = (byDate: boolean): string => fileKey(byDate ? "posdate" : "pos"); // у обычной раскладки и раскладки «по датам» свои хранилища +// раскладка из хранилища; записи неверной формы (не объект, нет чисел x/ry/w) отбрасываются +export function savedLayout(byDate: boolean): unknown { + const v = store.json(layoutKey(byDate), null); + if (!isObj(v)) return null; + const out: Record = {}; + Object.entries(v).forEach(([k, b]) => { if (isObj(b) && num(b.x) && num(b.ry) && num(b.w) && (b.h === undefined || num(b.h))) out[k] = b; }); + return out; +} +// сохраняем поверх уже записанного: карточки, скрытые текущим фильтром, не теряют свои места +// в режиме «по датам» x хранится относительно левой границы колонки даты: при сдвиге границ карточки едут вместе с колонкой +export function saveLayout(rel: Record, colBounds: Layout["colBounds"], byDate: boolean): void { + const raw = store.json(layoutKey(byDate), {}); + const out: Record = isObj(raw) ? raw : {}; + Object.entries(rel).forEach(([k, r]) => { + const x = r.x - (colBounds[k] ? colBounds[k].x0 : 0); + out[k] = r.auto ? { x, ry: r.ry, w: r.w } : { x, ry: r.ry, w: r.w, h: r.h }; + }); + store.set(layoutKey(byDate), JSON.stringify(out)); +} + +export interface BoardPrefs { filters: Filters; snap: boolean; table: boolean; problems: boolean } +// sprints/who пустые: их выбирает initialSprints/initialWho (нужна загруженная модель) +export function readPrefs(): BoardPrefs { + const on = (name: string): boolean => store.get(pref(name), "0") === "1"; + return { + filters: { ...noFilters(), neighbors: on("neighbors"), onlyLinked: on("onlylinked"), onlyHand: on("onlyhand"), crit: on("crit"), byDate: on("bydate"), + hideKind: new Set(strList(store.json(pref("hidekind"), []))), hideEdge: new Set(strList(store.json(pref("hideedge"), []))) }, + snap: store.get(pref("snap"), "1") !== "0", table: on("tbl"), problems: on("prob"), + }; +} +export type FlagName = "neighbors" | "onlyLinked" | "onlyHand" | "crit" | "byDate"; +const FLAG_KEY: Record = { + neighbors: "neighbors", onlyLinked: "onlylinked", onlyHand: "onlyhand", crit: "crit", byDate: "bydate", snap: "snap", table: "tbl", problems: "prob", +}; +export function writeFlag(name: FlagName | "snap" | "table" | "problems", on: boolean): void { store.set(pref(FLAG_KEY[name]), on ? "1" : "0"); } +export function writeHidden(hideKind: Set, hideEdge: Set): void { + store.set(pref("hidekind"), JSON.stringify([...hideKind])); + store.set(pref("hideedge"), JSON.stringify([...hideEdge])); +} +export const rememberedSprints = (): unknown => store.json(fileKey("sprint"), null); +export const rememberedWho = (): unknown => store.json(fileKey("who"), []); +export function writeSprints(s: Set): void { store.set(fileKey("sprint"), JSON.stringify([...s])); } +export function writeWho(s: Set): void { store.set(fileKey("who"), JSON.stringify([...s])); } + +export function toggleLane(name: string): void { + const s = foldedSet(); + if (s.has(name)) s.delete(name); else s.add(name); + store.set(fileKey("fold"), JSON.stringify([...s])); +} +// current — имена дорожек в текущем порядке; true — порядок изменён и записан +export function moveLane(current: string[], name: string, to: number): boolean { + const names = [...current], from = names.indexOf(name); + if (from < 0 || to < 0 || to >= names.length || from === to) return false; + names.splice(to, 0, names.splice(from, 1)[0]); + store.set(fileKey("lanes"), JSON.stringify(names.concat(laneOrder().filter((n) => !names.includes(n))))); + return true; +} +// ширины колонок дат, заданные вручную: {дата: px}; меньше COLW не бывает; null или w ≤ COLW — автоматическая ширина +export function setColWidth(date: string, w: number | null): void { + const cw = colWidths(); + if (w === null || w <= COLW) delete cw[date]; else cw[date] = Math.round(w / GRID) * GRID; + store.set(fileKey("colw"), JSON.stringify(cw)); +} +export function resetLayout(byDate: boolean): void { + [byDate ? "posdate" : "pos", "lanes", "fold"].concat(byDate ? ["colw"] : []).forEach((k) => store.del(fileKey(k))); +} + +// раскладка и фильтры из JSON кладутся в хранилище доски, так они применятся и к следующей загрузке данных; +// возвращает фильтры, которые импорт изменил (спринты и исполнителей выбирают initialSprints/initialWho из хранилища) +export function applyJsonSettings(r: JsonImport): Partial { + if (Object.keys(r.layout).length) store.set(fileKey("pos"), JSON.stringify(r.layout)); + if (Object.keys(r.layoutByDate).length) store.set(fileKey("posdate"), JSON.stringify(r.layoutByDate)); + if (r.laneOrder.length) store.set(fileKey("lanes"), JSON.stringify(r.laneOrder)); + if (r.collapsedLanes) store.set(fileKey("fold"), JSON.stringify(r.collapsedLanes)); + if (r.dateColumns) store.set(fileKey("colw"), JSON.stringify(r.dateColumns)); + const f = r.filters, out: Partial = {}; + if (Array.isArray(f.sprints)) store.set(fileKey("sprint"), JSON.stringify(strList(f.sprints))); + if (Array.isArray(f.assignees)) store.set(fileKey("who"), JSON.stringify(strList(f.assignees))); + if (f.mode === "all") store.set(fileKey("sprint"), "[]"); // «все задачи файла» = пустой выбор спринта (режим «linked» fromJson уже превратил в neighbors) + const flag = (src: string, name: FlagName): void => { + const v = f[src]; + if (typeof v === "boolean") { out[name] = v; writeFlag(name, v); } + }; + flag("neighbors", "neighbors"); flag("onlyLinked", "onlyLinked"); flag("onlyHand", "onlyHand"); flag("criticalPath", "crit"); flag("byDate", "byDate"); + if (Array.isArray(f.hiddenKinds)) { out.hideKind = new Set(strList(f.hiddenKinds)); store.set(pref("hidekind"), JSON.stringify([...out.hideKind])); } + if (Array.isArray(f.hiddenEdges)) { out.hideEdge = new Set(strList(f.hiddenEdges)); store.set(pref("hideedge"), JSON.stringify([...out.hideEdge])); } + return out; +}