feat(web): хранилище настроек доски с прежними ключами, импорт и экспорт JSON (#21)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-14 22:28:20 +04:00
parent b421687188
commit 5f012db829
4 changed files with 257 additions and 2 deletions

View File

@ -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<string, { x: number; ry: number; w: number; h?: number }>;
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`;
}

View File

@ -1,10 +1,9 @@
import { CW, KEY_RE, NO_EPIC } from "../constants.ts"; import { CW, KEY_RE, NO_EPIC } from "../constants.ts";
import type { BoardNode, Dicts, JsonImport, Link, SavedBox } from "../types.ts"; import type { BoardNode, Dicts, JsonImport, Link, SavedBox } from "../types.ts";
import { addLink, addStubs } from "./links.ts"; import { addLink, addStubs } from "./links.ts";
import { strList } from "../storage.ts";
import { sortableDate } from "./util.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<string, any>; // eslint-disable-line @typescript-eslint/no-explicit-any -- внешний JSON произвольной формы type Raw = Record<string, any>; // eslint-disable-line @typescript-eslint/no-explicit-any -- внешний JSON произвольной формы
// JSON, выгруженный старой страницей (см. exportJson): задачи, связи, раскладка, порядок и свёртка дорожек, фильтры // JSON, выгруженный старой страницей (см. exportJson): задачи, связи, раскладка, порядок и свёртка дорожек, фильтры

View File

@ -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<string, string> }[]) {
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();
});
});

142
web/src/board/storage.ts Normal file
View File

@ -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<T>(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<string, unknown> => !!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<object>("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<string> => new Set(strList(store.json(fileKey("fold"), [])));
export function colWidths(): Record<string, number> {
const v = store.json<unknown>(fileKey("colw"), {});
return isObj(v) ? v as Record<string, number> : {};
}
const layoutKey = (byDate: boolean): string => fileKey(byDate ? "posdate" : "pos"); // у обычной раскладки и раскладки «по датам» свои хранилища
// раскладка из хранилища; записи неверной формы (не объект, нет чисел x/ry/w) отбрасываются
export function savedLayout(byDate: boolean): unknown {
const v = store.json<unknown>(layoutKey(byDate), null);
if (!isObj(v)) return null;
const out: Record<string, unknown> = {};
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<string, RelBox>, colBounds: Layout["colBounds"], byDate: boolean): void {
const raw = store.json<unknown>(layoutKey(byDate), {});
const out: Record<string, unknown> = 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<FlagName | "snap" | "table" | "problems", string> = {
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<string>, hideEdge: Set<string>): void {
store.set(pref("hidekind"), JSON.stringify([...hideKind]));
store.set(pref("hideedge"), JSON.stringify([...hideEdge]));
}
export const rememberedSprints = (): unknown => store.json<unknown>(fileKey("sprint"), null);
export const rememberedWho = (): unknown => store.json<unknown>(fileKey("who"), []);
export function writeSprints(s: Set<string>): void { store.set(fileKey("sprint"), JSON.stringify([...s])); }
export function writeWho(s: Set<string>): 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<Filters> {
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<Filters> = {};
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;
}